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,324 @@
import { EncryptionService, SNRootKey } from '@standardnotes/encryption'
import { Challenge, ChallengeService } from '../Challenge'
import { ListedService } from '../Listed/ListedService'
import { ActionResponse, HttpResponse } from '@standardnotes/responses'
import { ContentType } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import {
SNActionsExtension,
Action,
ActionAccessType,
ActionsExtensionMutator,
MutationType,
CreateDecryptedItemFromPayload,
DecryptedItemInterface,
DecryptedPayloadInterface,
ActionExtensionContent,
EncryptedPayload,
isErrorDecryptingPayload,
CreateEncryptedBackupFileContextPayload,
EncryptedTransferPayload,
} from '@standardnotes/models'
import { SNSyncService } from '../Sync/SyncService'
import { PayloadManager } from '../Payloads/PayloadManager'
import { SNHttpService } from '../Api/HttpService'
import {
AbstractService,
DeviceInterface,
InternalEventBusInterface,
AlertService,
ChallengeValidation,
ChallengeReason,
ChallengePrompt,
} from '@standardnotes/services'
/**
* The Actions Service allows clients to interact with action-based extensions.
* Action-based extensions are mostly RESTful actions that can push a local value or
* retrieve a remote value and act on it accordingly.
* There are 4 action types:
* `get`: performs a GET request on an endpoint to retrieve an item value, and merges the
* value onto the local item value. For example, you can GET an item's older revision
* value and replace the current value with the revision.
* `render`: performs a GET request, and displays the result in the UI. This action does not
* affect data unless action is taken explicitely in the UI after the data is presented.
* `show`: opens the action's URL in a browser.
* `post`: sends an item's data to a remote service. This is used for example by Listed
* to allow publishing a note to a user's blog.
*/
export class SNActionsService extends AbstractService {
private previousPasswords: string[] = []
constructor(
private itemManager: ItemManager,
private alertService: AlertService,
public deviceInterface: DeviceInterface,
private httpService: SNHttpService,
private payloadManager: PayloadManager,
private protocolService: EncryptionService,
private syncService: SNSyncService,
private challengeService: ChallengeService,
private listedService: ListedService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.previousPasswords = []
}
public override deinit(): void {
;(this.itemManager as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.deviceInterface as unknown) = undefined
;(this.httpService as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.listedService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.protocolService as unknown) = undefined
;(this.syncService as unknown) = undefined
this.previousPasswords.length = 0
super.deinit()
}
public getExtensions(): SNActionsExtension[] {
const extensionItems = this.itemManager.getItems<SNActionsExtension>(ContentType.ActionsExtension)
const excludingListed = extensionItems.filter((extension) => !extension.isListedExtension)
return excludingListed
}
public extensionsInContextOfItem(item: DecryptedItemInterface) {
return this.getExtensions().filter((ext) => {
return ext.supported_types.includes(item.content_type) || ext.actionsWithContextForItem(item).length > 0
})
}
/**
* Loads an extension in the context of a certain item.
* The server then has the chance to respond with actions that are
* relevant just to this item. The response extension is not saved,
* just displayed as a one-time thing.
*/
public async loadExtensionInContextOfItem(
extension: SNActionsExtension,
item: DecryptedItemInterface,
): Promise<SNActionsExtension | undefined> {
const params = {
content_type: item.content_type,
item_uuid: item.uuid,
}
const response = (await this.httpService.getAbsolute(extension.url, params).catch((response) => {
console.error('Error loading extension', response)
return undefined
})) as ActionResponse
if (!response) {
return
}
const description = response.description || extension.description
const supported_types = response.supported_types || extension.supported_types
const actions = (response.actions || []) as Action[]
const mutator = new ActionsExtensionMutator(extension, MutationType.UpdateUserTimestamps)
mutator.deprecation = response.deprecation
mutator.description = description
mutator.supported_types = supported_types
mutator.actions = actions
const payloadResult = mutator.getResult()
return CreateDecryptedItemFromPayload(payloadResult) as SNActionsExtension
}
public async runAction(action: Action, item: DecryptedItemInterface): Promise<ActionResponse | undefined> {
let result
switch (action.verb) {
case 'render':
result = await this.handleRenderAction(action)
break
case 'show':
result = this.handleShowAction(action)
break
case 'post':
result = await this.handlePostAction(action, item)
break
default:
break
}
return result
}
private async handleRenderAction(action: Action): Promise<ActionResponse | undefined> {
const response = await this.httpService
.getAbsolute(action.url)
.then(async (response) => {
const payload = await this.payloadByDecryptingResponse(response as ActionResponse)
if (payload) {
const item = CreateDecryptedItemFromPayload<ActionExtensionContent, SNActionsExtension>(payload)
return {
...(response as ActionResponse),
item,
}
} else {
return undefined
}
})
.catch((response) => {
const error = (response && response.error) || {
message: 'An issue occurred while processing this action. Please try again.',
}
void this.alertService.alert(error.message)
return { error } as HttpResponse
})
return response as ActionResponse
}
private async payloadByDecryptingResponse(
response: ActionResponse,
rootKey?: SNRootKey,
triedPasswords: string[] = [],
): Promise<DecryptedPayloadInterface<ActionExtensionContent> | undefined> {
if (!response.item || response.item.deleted || response.item.content == undefined) {
return undefined
}
const payload = new EncryptedPayload(response.item as EncryptedTransferPayload)
if (!payload.enc_item_key) {
void this.alertService.alert('This revision is missing its key and cannot be recovered.')
return
}
let decryptedPayload = await this.protocolService.decryptSplitSingle<ActionExtensionContent>({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
})
if (!isErrorDecryptingPayload(decryptedPayload)) {
return decryptedPayload
}
if (rootKey) {
decryptedPayload = await this.protocolService.decryptSplitSingle({
usesRootKey: {
items: [payload],
key: rootKey,
},
})
if (!isErrorDecryptingPayload(decryptedPayload)) {
return decryptedPayload
}
}
for (const itemsKey of this.itemManager.getDisplayableItemsKeys()) {
const decryptedPayload = await this.protocolService.decryptSplitSingle<ActionExtensionContent>({
usesItemsKey: {
items: [payload],
key: itemsKey,
},
})
if (!isErrorDecryptingPayload(decryptedPayload)) {
return decryptedPayload
}
}
const keyParamsData = response.keyParams || response.auth_params
if (!keyParamsData) {
/**
* In some cases revisions were missing auth params.
* Instruct the user to email us to get this remedied.
*/
void this.alertService.alert(
'We were unable to decrypt this revision using your current keys, ' +
'and this revision is missing metadata that would allow us to try different ' +
'keys to decrypt it. This can likely be fixed with some manual intervention. ' +
'Please email help@standardnotes.com for assistance.',
)
return undefined
}
const keyParams = this.protocolService.createKeyParams(keyParamsData)
/* Try previous passwords */
for (const passwordCandidate of this.previousPasswords) {
if (triedPasswords.includes(passwordCandidate)) {
continue
}
triedPasswords.push(passwordCandidate)
const key = await this.protocolService.computeRootKey(passwordCandidate, keyParams)
if (!key) {
continue
}
const nestedResponse = await this.payloadByDecryptingResponse(response, key, triedPasswords)
if (nestedResponse) {
return nestedResponse
}
}
/** Prompt for other passwords */
const password = await this.promptForLegacyPassword()
if (!password) {
return undefined
}
if (this.previousPasswords.includes(password)) {
return undefined
}
this.previousPasswords.push(password)
return this.payloadByDecryptingResponse(response, rootKey)
}
private async promptForLegacyPassword(): Promise<string | undefined> {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, 'Previous Password', undefined, true)],
ChallengeReason.Custom,
true,
'Unable to find key for revision. Please enter the account password you may have used at the time of the revision.',
)
const response = await this.challengeService.promptForChallengeResponse(challenge)
return response?.getDefaultValue().value as string
}
private async handlePostAction(action: Action, item: DecryptedItemInterface) {
const decrypted = action.access_type === ActionAccessType.Decrypted
const itemParams = await this.outgoingPayloadForItem(item, decrypted)
const params = {
items: [itemParams],
}
return this.httpService
.postAbsolute(action.url, params)
.then((response) => {
return response as ActionResponse
})
.catch((response) => {
console.error('Action error response:', response)
void this.alertService.alert('An issue occurred while processing this action. Please try again.')
return response as ActionResponse
})
}
private handleShowAction(action: Action) {
void this.deviceInterface.openUrl(action.url)
return {} as ActionResponse
}
private async outgoingPayloadForItem(item: DecryptedItemInterface, decrypted = false) {
if (decrypted) {
return item.payload.ejected()
}
const encrypted = await this.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: { items: [item.payload] },
})
return CreateEncryptedBackupFileContextPayload(encrypted)
}
}

View File

@@ -0,0 +1,903 @@
import { FeatureDescription } from '@standardnotes/features'
import { isNullOrUndefined, joinPaths } from '@standardnotes/utils'
import { SettingName, SubscriptionSettingName } from '@standardnotes/settings'
import { Uuid, ErrorTag } from '@standardnotes/common'
import {
AbstractService,
ApiServiceInterface,
InternalEventBusInterface,
IntegrityApiInterface,
ItemsServerInterface,
StorageKey,
ApiServiceEvent,
MetaReceivedData,
DiagnosticInfo,
FilesApiInterface,
KeyValueStoreInterface,
} from '@standardnotes/services'
import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models'
import * as Responses from '@standardnotes/responses'
import { API_MESSAGE_FAILED_OFFLINE_ACTIVATION } from '@Lib/Services/Api/Messages'
import { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService'
import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
import { Paths } from './Paths'
import { Session } from '../Session/Sessions/Session'
import { TokenSession } from '../Session/Sessions/TokenSession'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { UserServerInterface } from '../User/UserServerInterface'
import { UuidString } from '../../Types/UuidString'
import * as messages from '@Lib/Services/Api/Messages'
import merge from 'lodash/merge'
import { SettingsServerInterface } from '../Settings/SettingsServerInterface'
import { Strings } from '@Lib/Strings'
import { SNRootKeyParams } from '@standardnotes/encryption'
import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { HttpResponseMeta } from '@standardnotes/api'
/** Legacy api version field to be specified in params when calling v0 APIs. */
const V0_API_VERSION = '20200115'
type InvalidSessionObserver = (revoked: boolean) => void
export class SNApiService
extends AbstractService<ApiServiceEvent.MetaReceived, MetaReceivedData>
implements
ApiServiceInterface,
FilesApiInterface,
IntegrityApiInterface,
ItemsServerInterface,
UserServerInterface,
SettingsServerInterface
{
private session?: Session
public user?: Responses.User
private registering = false
private authenticating = false
private changing = false
private refreshingSession = false
private invalidSessionObserver?: InvalidSessionObserver
private filesHost?: string
constructor(
private httpService: SNHttpService,
private storageService: DiskStorageService,
private host: string,
private inMemoryStore: KeyValueStoreInterface<string>,
private crypto: PureCryptoInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
override deinit(): void {
;(this.httpService as unknown) = undefined
;(this.storageService as unknown) = undefined
this.invalidSessionObserver = undefined
this.session = undefined
super.deinit()
}
public setUser(user?: Responses.User): void {
this.user = user
}
/**
* When a we receive a 401 error from the server, we'll notify the observer.
* Note that this applies only to sessions that are totally invalid. Sessions that
* are expired but can be renewed are still considered to be valid. In those cases,
* the server response is 498.
* If the session has been revoked, then the observer will have its first
* argument set to true.
*/
public setInvalidSessionObserver(observer: InvalidSessionObserver): void {
this.invalidSessionObserver = observer
}
public loadHost(): void {
const storedValue = this.storageService.getValue<string | undefined>(StorageKey.ServerHost)
this.host =
storedValue ||
this.host ||
((
window as {
_default_sync_server?: string
}
)._default_sync_server as string)
}
public async setHost(host: string): Promise<void> {
this.host = host
this.storageService.setValue(StorageKey.ServerHost, host)
}
public getHost(): string {
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')
}
return this.filesHost
}
public setSession(session: Session, persist = true): void {
this.session = session
if (persist) {
this.storageService.setValue(StorageKey.Session, session)
}
}
public getSession(): Session | undefined {
return this.session
}
public get apiVersion() {
return V0_API_VERSION
}
private params(inParams: Record<string | number | symbol, unknown>): HttpParams {
const params = merge(inParams, {
[ApiEndpointParam.ApiVersion]: this.apiVersion,
})
return params
}
public createErrorResponse(message: string, status?: Responses.StatusCode): Responses.HttpResponse {
return { error: { message, status } } as Responses.HttpResponse
}
private errorResponseWithFallbackMessage(response: Responses.HttpResponse, message: string) {
if (!response.error?.message) {
response.error = {
...response.error,
status: response.error?.status ?? Responses.StatusCode.UnknownError,
message,
}
}
return response
}
public processMetaObject(meta: HttpResponseMeta) {
if (meta.auth && meta.auth.userUuid && meta.auth.roles) {
void this.notifyEvent(ApiServiceEvent.MetaReceived, {
userUuid: meta.auth.userUuid,
userRoles: meta.auth.roles,
})
}
if (meta.server?.filesServerUrl) {
this.filesHost = meta.server?.filesServerUrl
}
}
private processResponse(response: Responses.HttpResponse) {
if (response.meta) {
this.processMetaObject(response.meta)
}
}
private async request(params: {
verb: HttpVerb
url: string
fallbackErrorMessage: string
params?: HttpParams
rawBytes?: Uint8Array
authentication?: string
customHeaders?: Record<string, string>[]
responseType?: XMLHttpRequestResponseType
external?: boolean
}) {
try {
const response = await this.httpService.runHttp(params)
this.processResponse(response)
return response
} catch (errorResponse) {
return this.errorResponseWithFallbackMessage(errorResponse as Responses.HttpResponse, params.fallbackErrorMessage)
}
}
/**
* @param mfaKeyPath The params path the server expects for authentication against
* a particular mfa challenge. A value of foo would mean the server
* would receive parameters as params['foo'] with value equal to mfaCode.
* @param mfaCode The mfa challenge response value.
*/
async getAccountKeyParams(dto: {
email: string
mfaKeyPath?: string
mfaCode?: string
}): Promise<Responses.KeyParamsResponse | Responses.HttpResponse> {
const codeVerifier = this.crypto.generateRandomKey(256)
this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier)
const codeChallenge = this.crypto.base64URLEncode(await this.crypto.sha256(codeVerifier))
const params = this.params({
email: dto.email,
code_challenge: codeChallenge,
})
if (dto.mfaKeyPath !== undefined && dto.mfaCode !== undefined) {
params[dto.mfaKeyPath] = dto.mfaCode
}
return this.request({
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v2.keyParams),
fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN,
params,
/** A session is optional here, if valid, endpoint bypasses 2FA and returns additional params */
authentication: this.session?.authorizationValue,
})
}
async signIn(dto: {
email: string
serverPassword: string
ephemeral: boolean
}): Promise<Responses.SignInResponse | Responses.HttpResponse> {
if (this.authenticating) {
return this.createErrorResponse(messages.API_MESSAGE_LOGIN_IN_PROGRESS) as Responses.SignInResponse
}
this.authenticating = true
const url = joinPaths(this.host, Paths.v2.signIn)
const params = this.params({
email: dto.email,
password: dto.serverPassword,
ephemeral: dto.ephemeral,
code_verifier: this.inMemoryStore.getValue(StorageKey.CodeVerifier) as string,
})
const response = await this.request({
verb: HttpVerb.Post,
url,
params,
fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN,
})
this.authenticating = false
this.inMemoryStore.removeValue(StorageKey.CodeVerifier)
return response
}
signOut(): Promise<Responses.SignOutResponse> {
const url = joinPaths(this.host, Paths.v1.signOut)
return this.httpService.postAbsolute(url, undefined, this.session?.authorizationValue).catch((errorResponse) => {
return errorResponse
}) as Promise<Responses.SignOutResponse>
}
async changeCredentials(parameters: {
userUuid: UuidString
currentServerPassword: string
newServerPassword: string
newKeyParams: SNRootKeyParams
newEmail?: string
}): Promise<Responses.ChangeCredentialsResponse | Responses.HttpResponse> {
if (this.changing) {
return this.createErrorResponse(messages.API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS)
}
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
this.changing = true
const url = joinPaths(this.host, Paths.v1.changeCredentials(parameters.userUuid) as string)
const params = this.params({
current_password: parameters.currentServerPassword,
new_password: parameters.newServerPassword,
new_email: parameters.newEmail,
...parameters.newKeyParams.getPortableValue(),
})
const response = await this.httpService
.putAbsolute(url, params, this.session?.authorizationValue)
.catch(async (errorResponse) => {
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Put,
url,
params,
})
}
return this.errorResponseWithFallbackMessage(
errorResponse,
messages.API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL,
)
})
this.processResponse(response)
this.changing = false
return response
}
public async deleteAccount(userUuid: string): Promise<Responses.HttpResponse | Responses.MinimalHttpResponse> {
const url = joinPaths(this.host, Paths.v1.deleteAccount(userUuid))
const response = await this.request({
verb: HttpVerb.Delete,
url,
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.ServerErrorStrings.DeleteAccountError,
})
return response
}
async sync(
payloads: ServerSyncPushContextualPayload[],
lastSyncToken: string,
paginationToken: string,
limit: number,
): Promise<Responses.RawSyncResponse | Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
const url = joinPaths(this.host, Paths.v1.sync)
const params = this.params({
[ApiEndpointParam.SyncPayloads]: payloads,
[ApiEndpointParam.LastSyncToken]: lastSyncToken,
[ApiEndpointParam.PaginationToken]: paginationToken,
[ApiEndpointParam.SyncDlLimit]: limit,
})
const response = await this.httpService
.postAbsolute(url, params, this.session?.authorizationValue)
.catch<Responses.HttpResponse>(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Post,
url,
params,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
private async refreshSessionThenRetryRequest(httpRequest: HttpRequest): Promise<Responses.HttpResponse> {
const sessionResponse = await this.refreshSession()
if (sessionResponse.error || isNullOrUndefined(sessionResponse.data)) {
return sessionResponse
} else {
return this.httpService
.runHttp({
...httpRequest,
authentication: this.session?.authorizationValue,
})
.catch((errorResponse) => {
return errorResponse
})
}
}
async refreshSession(): Promise<Responses.SessionRenewalResponse | Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
this.refreshingSession = true
const url = joinPaths(this.host, Paths.v1.refreshSession)
const session = this.session as TokenSession
const params = this.params({
access_token: session.accessToken,
refresh_token: session.refreshToken,
})
const result = await this.httpService
.postAbsolute(url, params)
.then(async (response) => {
const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse)
await this.setSession(session)
this.processResponse(response)
return response
})
.catch((errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL)
})
this.refreshingSession = false
return result
}
async getSessionsList(): Promise<Responses.SessionListResponse | Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
const url = joinPaths(this.host, Paths.v1.sessions)
const response = await this.httpService
.getAbsolute(url, {}, this.session?.authorizationValue)
.catch(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Get,
url,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
async deleteSession(sessionId: UuidString): Promise<Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.deleteAbsolute(url, { uuid: sessionId }, this.session?.authorizationValue)
.catch((error: Responses.HttpResponse) => {
const errorResponse = error as Responses.HttpResponse
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Delete,
url,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
async getItemRevisions(itemId: UuidString): Promise<Responses.RevisionListResponse | Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Get,
url,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
async getRevision(
entry: Responses.RevisionListEntry,
itemId: UuidString,
): Promise<Responses.SingleRevisionResponse | Responses.HttpResponse> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError
}
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Get,
url,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
const response = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
verb: HttpVerb.Get,
url,
})
}
return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL)
})
this.processResponse(response)
return response
}
private async tokenRefreshableRequest<T extends Responses.MinimalHttpResponse>(
params: HttpRequest & { fallbackErrorMessage: string },
): Promise<T> {
const preprocessingError = this.preprocessingError()
if (preprocessingError) {
return preprocessingError as T
}
const response: T | Responses.HttpResponse = await this.httpService
.runHttp(params)
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest(params)
}
return this.errorResponseWithFallbackMessage(errorResponse, params.fallbackErrorMessage)
})
this.processResponse(response)
return response as T
}
async listSettings(userUuid: UuidString): Promise<Responses.ListSettingsResponse> {
return await this.tokenRefreshableRequest<Responses.ListSettingsResponse>({
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS,
authentication: this.session?.authorizationValue,
})
}
async updateSetting(
userUuid: UuidString,
settingName: string,
settingValue: string | null,
sensitive: boolean,
): Promise<Responses.UpdateSettingResponse> {
const params = {
name: settingName,
value: settingValue,
sensitive: sensitive,
}
return this.tokenRefreshableRequest<Responses.UpdateSettingResponse>({
verb: HttpVerb.Put,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_UPDATE_SETTINGS,
params,
})
}
async getSetting(userUuid: UuidString, settingName: SettingName): Promise<Responses.GetSettingResponse> {
return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)),
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS,
})
}
async getSubscriptionSetting(
userUuid: UuidString,
settingName: SubscriptionSettingName,
): Promise<Responses.GetSettingResponse> {
return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({
verb: HttpVerb.Get,
url: joinPaths(
this.host,
Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName),
),
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS,
})
}
async deleteSetting(userUuid: UuidString, settingName: SettingName): Promise<Responses.DeleteSettingResponse> {
return this.tokenRefreshableRequest<Responses.DeleteSettingResponse>({
verb: HttpVerb.Delete,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)),
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_UPDATE_SETTINGS,
})
}
async deleteRevision(
itemUuid: UuidString,
entry: Responses.RevisionListEntry,
): Promise<Responses.MinimalHttpResponse> {
const url = joinPaths(this.host, Paths.v1.itemRevision(itemUuid, entry.uuid))
const response = await this.tokenRefreshableRequest({
verb: HttpVerb.Delete,
url,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_DELETE_REVISION,
authentication: this.session?.authorizationValue,
})
return response
}
public downloadFeatureUrl(url: string): Promise<Responses.HttpResponse> {
return this.request({
verb: HttpVerb.Get,
url,
external: true,
fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN,
})
}
public async getSubscription(userUuid: string): Promise<Responses.HttpResponse | Responses.GetSubscriptionResponse> {
const url = joinPaths(this.host, Paths.v1.subscription(userUuid))
const response = await this.tokenRefreshableRequest({
verb: HttpVerb.Get,
url,
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
})
return response
}
public async getAvailableSubscriptions(): Promise<
Responses.HttpResponse | Responses.GetAvailableSubscriptionsResponse
> {
const url = joinPaths(this.host, Paths.v2.subscriptions)
const response = await this.request({
verb: HttpVerb.Get,
url,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
})
return response
}
public async getNewSubscriptionToken(): Promise<string | undefined> {
const url = joinPaths(this.host, Paths.v1.subscriptionTokens)
const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({
verb: HttpVerb.Post,
url,
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_ACCESS_PURCHASE,
})
return (response as Responses.PostSubscriptionTokensResponse).data?.token
}
public async downloadOfflineFeaturesFromRepo(
repo: SNFeatureRepo,
): Promise<{ features: FeatureDescription[] } | ClientDisplayableError> {
try {
const featuresUrl = repo.offlineFeaturesUrl
const extensionKey = repo.offlineKey
if (!featuresUrl || !extensionKey) {
throw Error('Cannot download offline repo without url and offlineKEy')
}
const { host } = new URL(featuresUrl)
if (!TRUSTED_FEATURE_HOSTS.includes(host)) {
return new ClientDisplayableError('This offline features host is not in the trusted allowlist.')
}
const response: Responses.HttpResponse | Responses.GetOfflineFeaturesResponse = await this.request({
verb: HttpVerb.Get,
url: featuresUrl,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_OFFLINE_FEATURES,
customHeaders: [{ key: 'x-offline-token', value: extensionKey }],
})
if (response.error) {
return ClientDisplayableError.FromError(response.error)
}
return {
features: (response as Responses.GetOfflineFeaturesResponse).data?.features || [],
}
} catch {
return new ClientDisplayableError(API_MESSAGE_FAILED_OFFLINE_ACTIVATION)
}
}
public async registerForListedAccount(): Promise<Responses.ListedRegistrationResponse> {
if (!this.user) {
throw Error('Cannot register for Listed without user account.')
}
return await this.tokenRefreshableRequest<Responses.ListedRegistrationResponse>({
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)),
fallbackErrorMessage: messages.API_MESSAGE_FAILED_LISTED_REGISTRATION,
authentication: this.session?.authorizationValue,
})
}
public async createFileValetToken(
remoteIdentifier: string,
operation: 'write' | 'read' | 'delete',
unencryptedFileSize?: number,
): Promise<string | ClientDisplayableError> {
const url = joinPaths(this.host, Paths.v1.createFileValetToken)
const params: CreateValetTokenPayload = {
operation,
resources: [{ remoteIdentifier, unencryptedFileSize: unencryptedFileSize || 0 }],
}
const response = await this.tokenRefreshableRequest<Responses.CreateValetTokenResponse>({
verb: HttpVerb.Post,
url: url,
authentication: this.session?.authorizationValue,
fallbackErrorMessage: messages.API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
params,
})
if (!response.data?.success) {
return new ClientDisplayableError(response.data?.reason as string, undefined, response.data?.reason as string)
}
return response.data?.valetToken
}
public async startUploadSession(apiToken: string): Promise<Responses.StartUploadSessionResponse> {
const url = joinPaths(this.getFilesHost(), Paths.v1.startUploadSession)
const response: Responses.HttpResponse | Responses.StartUploadSessionResponse = await this.tokenRefreshableRequest({
verb: HttpVerb.Post,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
fallbackErrorMessage: Strings.Network.Files.FailedStartUploadSession,
})
return response as Responses.StartUploadSessionResponse
}
public async deleteFile(apiToken: string): Promise<Responses.MinimalHttpResponse> {
const url = joinPaths(this.getFilesHost(), Paths.v1.deleteFile)
const response: Responses.HttpResponse | Responses.StartUploadSessionResponse = await this.tokenRefreshableRequest({
verb: HttpVerb.Delete,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
fallbackErrorMessage: Strings.Network.Files.FailedDeleteFile,
})
return response as Responses.MinimalHttpResponse
}
public async uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise<boolean> {
if (chunkId === 0) {
throw Error('chunkId must start with 1')
}
const url = joinPaths(this.getFilesHost(), Paths.v1.uploadFileChunk)
const response: Responses.HttpResponse | Responses.UploadFileChunkResponse = await this.tokenRefreshableRequest({
verb: HttpVerb.Post,
url,
rawBytes: encryptedBytes,
customHeaders: [
{ key: 'x-valet-token', value: apiToken },
{ key: 'x-chunk-id', value: chunkId.toString() },
{ key: 'Content-Type', value: 'application/octet-stream' },
],
fallbackErrorMessage: Strings.Network.Files.FailedUploadFileChunk,
})
return (response as Responses.UploadFileChunkResponse).success
}
public async closeUploadSession(apiToken: string): Promise<boolean> {
const url = joinPaths(this.getFilesHost(), Paths.v1.closeUploadSession)
const response: Responses.HttpResponse | Responses.CloseUploadSessionResponse = await this.tokenRefreshableRequest({
verb: HttpVerb.Post,
url,
customHeaders: [{ key: 'x-valet-token', value: apiToken }],
fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession,
})
return (response as Responses.CloseUploadSessionResponse).success
}
public getFilesDownloadUrl(): string {
return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk)
}
public async downloadFile(
file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] },
chunkIndex = 0,
apiToken: string,
contentRangeStart: number,
onBytesReceived: (bytes: Uint8Array) => Promise<void>,
): Promise<ClientDisplayableError | undefined> {
const url = this.getFilesDownloadUrl()
const pullChunkSize = file.encryptedChunkSizes[chunkIndex]
const response: Responses.HttpResponse | Responses.DownloadFileChunkResponse =
await this.tokenRefreshableRequest<Responses.DownloadFileChunkResponse>({
verb: HttpVerb.Get,
url,
customHeaders: [
{ key: 'x-valet-token', value: apiToken },
{
key: 'x-chunk-size',
value: pullChunkSize.toString(),
},
{ key: 'range', value: `bytes=${contentRangeStart}-` },
],
fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk,
responseType: 'arraybuffer',
})
const contentRangeHeader = (<Map<string, string | null>>response.headers).get('content-range')
if (!contentRangeHeader) {
return new ClientDisplayableError('Could not obtain content-range header while downloading file chunk')
}
const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/)
if (!matches || matches.length !== 5) {
return new ClientDisplayableError('Malformed content-range header in response when downloading file chunk')
}
const rangeStart = +matches[2]
const rangeEnd = +matches[3]
const totalSize = +matches[4]
const bytesReceived = new Uint8Array(response.data as ArrayBuffer)
await onBytesReceived(bytesReceived)
if (rangeEnd < totalSize - 1) {
return this.downloadFile(file, ++chunkIndex, apiToken, rangeStart + pullChunkSize, onBytesReceived)
}
return undefined
}
async checkIntegrity(integrityPayloads: Responses.IntegrityPayload[]): Promise<Responses.CheckIntegrityResponse> {
return await this.tokenRefreshableRequest<Responses.CheckIntegrityResponse>({
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v1.checkIntegrity),
params: {
integrityPayloads,
},
fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL,
authentication: this.session?.authorizationValue,
})
}
async getSingleItem(itemUuid: Uuid): Promise<Responses.GetSingleItemResponse> {
return await this.tokenRefreshableRequest<Responses.GetSingleItemResponse>({
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)),
fallbackErrorMessage: messages.API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL,
authentication: this.session?.authorizationValue,
})
}
private preprocessingError() {
if (this.refreshingSession) {
return this.createErrorResponse(messages.API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS)
}
if (!this.session) {
return this.createErrorResponse(messages.API_MESSAGE_INVALID_SESSION)
}
return undefined
}
/** Handle errored responses to authenticated requests */
private preprocessAuthenticatedErrorResponse(response: Responses.HttpResponse) {
if (response.status === Responses.StatusCode.HttpStatusInvalidSession && this.session) {
this.invalidSessionObserver?.(response.error?.tag === ErrorTag.RevokedSession)
}
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
api: {
hasSession: this.session != undefined,
user: this.user,
registering: this.registering,
authenticating: this.authenticating,
changing: this.changing,
refreshingSession: this.refreshingSession,
filesHost: this.filesHost,
host: this.host,
},
})
}
}

View File

@@ -0,0 +1,211 @@
import { API_MESSAGE_RATE_LIMITED, UNKNOWN_ERROR } from './Messages'
import { HttpResponse, StatusCode } from '@standardnotes/responses'
import { isString } from '@standardnotes/utils'
import { SnjsVersion } from '@Lib/Version'
import { AbstractService, InternalEventBusInterface, Environment } from '@standardnotes/services'
export enum HttpVerb {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Patch = 'PATCH',
Delete = 'DELETE',
}
const REQUEST_READY_STATE_COMPLETED = 4
export type HttpParams = Record<string, unknown>
export type HttpRequest = {
url: string
params?: HttpParams
rawBytes?: Uint8Array
verb: HttpVerb
authentication?: string
customHeaders?: Record<string, string>[]
responseType?: XMLHttpRequestResponseType
external?: boolean
}
/**
* A non-SNJS specific wrapper for XMLHttpRequests
*/
export class SNHttpService extends AbstractService {
constructor(
private readonly environment: Environment,
private readonly appVersion: string,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public async getAbsolute(url: string, params?: HttpParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url, params, verb: HttpVerb.Get, authentication })
}
public async postAbsolute(url: string, params?: HttpParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url, params, verb: HttpVerb.Post, authentication })
}
public async putAbsolute(url: string, params?: HttpParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url, params, verb: HttpVerb.Put, authentication })
}
public async patchAbsolute(url: string, params: HttpParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url, params, verb: HttpVerb.Patch, authentication })
}
public async deleteAbsolute(url: string, params?: HttpParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url, params, verb: HttpVerb.Delete, authentication })
}
public async runHttp(httpRequest: HttpRequest): Promise<HttpResponse> {
const request = this.createXmlRequest(httpRequest)
return this.runRequest(request, this.createRequestBody(httpRequest))
}
private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined {
if (
httpRequest.params !== undefined &&
[HttpVerb.Post, HttpVerb.Put, HttpVerb.Patch, HttpVerb.Delete].includes(httpRequest.verb)
) {
return JSON.stringify(httpRequest.params)
}
return httpRequest.rawBytes
}
private createXmlRequest(httpRequest: HttpRequest) {
const request = new XMLHttpRequest()
if (httpRequest.params && httpRequest.verb === HttpVerb.Get && Object.keys(httpRequest.params).length > 0) {
httpRequest.url = this.urlForUrlAndParams(httpRequest.url, httpRequest.params)
}
request.open(httpRequest.verb, httpRequest.url, true)
request.responseType = httpRequest.responseType ?? ''
if (!httpRequest.external) {
request.setRequestHeader('X-SNJS-Version', SnjsVersion)
const appVersionHeaderValue = `${Environment[this.environment]}-${this.appVersion}`
request.setRequestHeader('X-Application-Version', appVersionHeaderValue)
if (httpRequest.authentication) {
request.setRequestHeader('Authorization', 'Bearer ' + httpRequest.authentication)
}
}
let contenTypeIsSet = false
if (httpRequest.customHeaders && httpRequest.customHeaders.length > 0) {
httpRequest.customHeaders.forEach(({ key, value }) => {
request.setRequestHeader(key, value)
if (key === 'Content-Type') {
contenTypeIsSet = true
}
})
}
if (!contenTypeIsSet && !httpRequest.external) {
request.setRequestHeader('Content-Type', 'application/json')
}
return request
}
private async runRequest(request: XMLHttpRequest, body?: string | Uint8Array): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
request.onreadystatechange = () => {
this.stateChangeHandlerForRequest(request, resolve, reject)
}
request.send(body)
})
}
private stateChangeHandlerForRequest(
request: XMLHttpRequest,
resolve: (response: HttpResponse) => void,
reject: (response: HttpResponse) => void,
) {
if (request.readyState !== REQUEST_READY_STATE_COMPLETED) {
return
}
const httpStatus = request.status
const response: HttpResponse = {
status: httpStatus,
headers: new Map<string, string | null>(),
}
const responseHeaderLines = request
.getAllResponseHeaders()
?.trim()
.split(/[\r\n]+/)
responseHeaderLines?.forEach((responseHeaderLine) => {
const parts = responseHeaderLine.split(': ')
const name = parts.shift() as string
const value = parts.join(': ')
;(<Map<string, string | null>>response.headers).set(name, value)
})
try {
if (httpStatus !== StatusCode.HttpStatusNoContent) {
let body
const contentTypeHeader = response.headers?.get('content-type') || response.headers?.get('Content-Type')
if (contentTypeHeader?.includes('application/json')) {
body = JSON.parse(request.responseText)
} else {
body = request.response
}
/**
* v0 APIs do not have a `data` top-level object. In such cases, mimic
* the newer response body style by putting all the top-level
* properties inside a `data` object.
*/
if (!body.data) {
response.data = body
}
if (!isString(body)) {
Object.assign(response, body)
}
}
} catch (error) {
console.error(error)
}
if (httpStatus >= StatusCode.HttpStatusMinSuccess && httpStatus <= StatusCode.HttpStatusMaxSuccess) {
resolve(response)
} else {
if (httpStatus === StatusCode.HttpStatusForbidden) {
response.error = {
message: API_MESSAGE_RATE_LIMITED,
status: httpStatus,
}
} else if (response.error == undefined) {
if (response.data == undefined || response.data.error == undefined) {
try {
response.error = { message: request.responseText || UNKNOWN_ERROR, status: httpStatus }
} catch (error) {
response.error = { message: UNKNOWN_ERROR, status: httpStatus }
}
} else {
response.error = response.data.error
}
}
reject(response)
}
}
private urlForUrlAndParams(url: string, params: HttpParams) {
const keyValueString = Object.keys(params)
.map((key) => {
return key + '=' + encodeURIComponent(params[key] as string)
})
.join('&')
if (url.includes('?')) {
return url + '&' + keyValueString
} else {
return url + '?' + keyValueString
}
}
}

View File

@@ -0,0 +1,185 @@
import { ProtocolVersion } from '@standardnotes/common'
export const API_MESSAGE_GENERIC_INVALID_LOGIN = 'A server error occurred while trying to sign in. Please try again.'
export const API_MESSAGE_GENERIC_REGISTRATION_FAIL =
'A server error occurred while trying to register. Please try again.'
export const API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL =
'Something went wrong while changing your credentials. Your credentials were not changed. Please try again.'
export const API_MESSAGE_GENERIC_SYNC_FAIL = 'Could not connect to server.'
export const ServerErrorStrings = {
DeleteAccountError: 'Your account was unable to be deleted due to an error. Please try your request again.',
}
export const API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL = 'Could not check your data integrity with the server.'
export const API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL = 'Could not retrieve item.'
export const API_MESSAGE_REGISTRATION_IN_PROGRESS = 'An existing registration request is already in progress.'
export const API_MESSAGE_LOGIN_IN_PROGRESS = 'An existing sign in request is already in progress.'
export const API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS =
'An existing change credentials request is already in progress.'
export const API_MESSAGE_FALLBACK_LOGIN_FAIL = 'Invalid email or password.'
export const API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL =
'A server error occurred while trying to refresh your session. Please try again.'
export const API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS =
'Your account session is being renewed with the server. Please try your request again.'
export const API_MESSAGE_RATE_LIMITED = 'Too many successive server requests. Please wait a few minutes and try again.'
export const API_MESSAGE_INVALID_SESSION = 'Please sign in to an account in order to continue with your request.'
export const API_MESSAGE_FAILED_GET_SETTINGS = 'Failed to get settings.'
export const API_MESSAGE_FAILED_UPDATE_SETTINGS = 'Failed to update settings.'
export const API_MESSAGE_FAILED_LISTED_REGISTRATION = 'Unable to register for Listed. Please try again later.'
export const API_MESSAGE_FAILED_CREATE_FILE_TOKEN = 'Failed to create file token.'
export const API_MESSAGE_FAILED_SUBSCRIPTION_INFO = "Failed to get subscription's information."
export const API_MESSAGE_FAILED_ACCESS_PURCHASE = 'Failed to access purchase flow.'
export const API_MESSAGE_FAILED_DELETE_REVISION = 'Failed to delete revision.'
export const API_MESSAGE_FAILED_OFFLINE_FEATURES = 'Failed to get offline features.'
export const API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING = `The extension you are attempting to install comes from an
untrusted source. Untrusted extensions may lower the security of your data. Do you want to continue?`
export const API_MESSAGE_FAILED_DOWNLOADING_EXTENSION = `Error downloading package details. Please check the
extension link and try again.`
export const API_MESSAGE_FAILED_OFFLINE_ACTIVATION =
'An unknown issue occurred during offline activation. Please try again.'
export const INVALID_EXTENSION_URL = 'Invalid extension URL.'
export const UNSUPPORTED_PROTOCOL_VERSION =
'This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.'
export const EXPIRED_PROTOCOL_VERSION =
'The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.'
export const UNSUPPORTED_KEY_DERIVATION =
'Your account was created on a platform with higher security capabilities than this browser supports. If we attempted to generate your login keys here, it would take hours. Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to log in.'
export const INVALID_PASSWORD_COST =
'Unable to sign in due to insecure password parameters. Please visit standardnotes.com/help/security for more information.'
export const INVALID_PASSWORD = 'Invalid password.'
export const OUTDATED_PROTOCOL_ALERT_IGNORE = 'Sign In'
export const UPGRADING_ENCRYPTION = "Upgrading your account's encryption version…"
export const SETTING_PASSCODE = 'Setting passcode…'
export const CHANGING_PASSCODE = 'Changing passcode…'
export const REMOVING_PASSCODE = 'Removing passcode…'
export const DO_NOT_CLOSE_APPLICATION = 'Do not close the application until this process completes.'
export const UNKNOWN_ERROR = 'Unknown error.'
export function InsufficientPasswordMessage(minimum: number): string {
return `Your password must be at least ${minimum} characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.`
}
export function StrictSignInFailed(current: ProtocolVersion, latest: ProtocolVersion): string {
return `Strict Sign In has refused the server's sign-in parameters. The latest account version is ${latest}, but the server is reporting a version of ${current} for your account. If you'd like to proceed with sign in anyway, please disable Strict Sign In and try again.`
}
export const CredentialsChangeStrings = {
PasscodeRequired: 'Your passcode is required to process your credentials change.',
Failed: 'Unable to change your credentials due to a sync error. Please try again.',
}
export const RegisterStrings = {
PasscodeRequired: 'Your passcode is required in order to register for an account.',
}
export const SignInStrings = {
PasscodeRequired: 'Your passcode is required in order to sign in to your account.',
IncorrectMfa: 'Incorrect two-factor authentication code. Please try again.',
SignInCanceledMissingMfa: 'Your sign in request has been canceled.',
}
export const ProtocolUpgradeStrings = {
SuccessAccount:
"Your encryption version has been successfully upgraded. You may be asked to enter your credentials again on other devices you're signed into.",
SuccessPasscodeOnly: 'Your encryption version has been successfully upgraded.',
Fail: 'Unable to upgrade encryption version. Please try again.',
UpgradingPasscode: 'Upgrading local encryption...',
}
export const ChallengeModalTitle = {
Generic: 'Authentication Required',
Migration: 'Storage Update',
}
export const SessionStrings = {
EnterEmailAndPassword: 'Please enter your account email and password.',
RecoverSession(email?: string): string {
return email
? `Your credentials are needed for ${email} to refresh your session with the server.`
: 'Your credentials are needed to refresh your session with the server.'
},
SessionRestored: 'Your session has been successfully restored.',
EnterMfa: 'Please enter your two-factor authentication code.',
MfaInputPlaceholder: 'Two-factor authentication code',
EmailInputPlaceholder: 'Email',
PasswordInputPlaceholder: 'Password',
KeychainRecoveryErrorTitle: 'Invalid Credentials',
KeychainRecoveryError:
'The email or password you entered is incorrect.\n\nPlease note that this sign-in request is made against the default server. If you are using a custom server, you must uninstall the app then reinstall, and sign back into your account.',
RevokeTitle: 'Revoke this session?',
RevokeConfirmButton: 'Revoke',
RevokeCancelButton: 'Cancel',
RevokeText:
'The associated app will be signed out and all data removed ' +
'from the device when it is next launched. You can sign back in on that ' +
'device at any time.',
CurrentSessionRevoked: 'Your session has been revoked and all local data has been removed ' + 'from this device.',
}
export const ChallengeStrings = {
UnlockApplication: 'Authentication is required to unlock the application',
NoteAccess: 'Authentication is required to view this note',
FileAccess: 'Authentication is required to access this file',
ImportFile: 'Authentication is required to import a backup file',
AddPasscode: 'Authentication is required to add a passcode',
RemovePasscode: 'Authentication is required to remove your passcode',
ChangePasscode: 'Authentication is required to change your passcode',
ChangeAutolockInterval: 'Authentication is required to change autolock timer duration',
RevokeSession: 'Authentication is required to revoke a session',
EnterAccountPassword: 'Enter your account password',
EnterLocalPasscode: 'Enter your application passcode',
EnterPasscodeForMigration:
'Your application passcode is required to perform an upgrade of your local data storage structure.',
EnterPasscodeForRootResave: 'Enter your application passcode to continue',
EnterCredentialsForProtocolUpgrade: 'Enter your credentials to perform encryption upgrade',
EnterCredentialsForDecryptedBackupDownload: 'Enter your credentials to download a decrypted backup',
AccountPasswordPlaceholder: 'Account Password',
LocalPasscodePlaceholder: 'Application Passcode',
DecryptEncryptedFile: 'Enter the account password associated with the import file',
ExportBackup: 'Authentication is required to export a backup',
DisableBiometrics: 'Authentication is required to disable biometrics',
UnprotectNote: 'Authentication is required to unprotect a note',
UnprotectFile: 'Authentication is required to unprotect a file',
SearchProtectedNotesText: 'Authentication is required to search protected contents',
SelectProtectedNote: 'Authentication is required to select a protected note',
DisableMfa: 'Authentication is required to disable two-factor authentication',
DeleteAccount: 'Authentication is required to delete your account',
}
export const ErrorAlertStrings = {
MissingSessionTitle: 'Missing Session',
MissingSessionBody:
'We were unable to load your server session. This represents an inconsistency with your application state. Please take an opportunity to backup your data, then sign out and sign back in to resolve this issue.',
StorageDecryptErrorTitle: 'Storage Error',
StorageDecryptErrorBody:
"We were unable to decrypt your local storage. Please restart the app and try again. If you're unable to resolve this issue, and you have an account, you may try uninstalling the app then reinstalling, then signing back into your account. Otherwise, please contact help@standardnotes.org for support.",
}
export const KeychainRecoveryStrings = {
Title: 'Restore Keychain',
Text: "We've detected that your keychain has been wiped. This can happen when restoring your device from a backup. Please enter your account password to restore your account keys.",
}

View File

@@ -0,0 +1,74 @@
import { Uuid } from '@standardnotes/common'
import { SettingName, SubscriptionSettingName } from '@standardnotes/settings'
const FilesPaths = {
closeUploadSession: '/v1/files/upload/close-session',
createFileValetToken: '/v1/files/valet-tokens',
deleteFile: '/v1/files',
downloadFileChunk: '/v1/files',
startUploadSession: '/v1/files/upload/create-session',
uploadFileChunk: '/v1/files/upload/chunk',
}
const UserPaths = {
changeCredentials: (userUuid: string) => `/v1/users/${userUuid}/attributes/credentials`,
deleteAccount: (userUuid: Uuid) => `/v1/users/${userUuid}`,
keyParams: '/v1/login-params',
refreshSession: '/v1/sessions/refresh',
register: '/v1/users',
session: (sessionUuid: string) => `/v1/sessions/${sessionUuid}`,
sessions: '/v1/sessions',
signIn: '/v1/login',
signOut: '/v1/logout',
}
const ItemsPaths = {
checkIntegrity: '/v1/items/check-integrity',
getSingleItem: (uuid: Uuid) => `/v1/items/${uuid}`,
itemRevisions: (itemUuid: string) => `/v1/items/${itemUuid}/revisions`,
itemRevision: (itemUuid: string, revisionUuid: string) => `/v1/items/${itemUuid}/revisions/${revisionUuid}`,
sync: '/v1/items',
}
const SettingsPaths = {
settings: (userUuid: Uuid) => `/v1/users/${userUuid}/settings`,
setting: (userUuid: Uuid, settingName: SettingName) => `/v1/users/${userUuid}/settings/${settingName}`,
subscriptionSetting: (userUuid: Uuid, settingName: SubscriptionSettingName) =>
`/v1/users/${userUuid}/subscription-settings/${settingName}`,
}
const SubscriptionPaths = {
offlineFeatures: '/v1/offline/features',
purchase: '/v1/purchase',
subscription: (userUuid: Uuid) => `/v1/users/${userUuid}/subscription`,
subscriptionTokens: '/v1/subscription-tokens',
userFeatures: (userUuid: string) => `/v1/users/${userUuid}/features`,
}
const SubscriptionPathsV2 = {
subscriptions: '/v2/subscriptions',
}
const UserPathsV2 = {
keyParams: '/v2/login-params',
signIn: '/v2/login',
}
const ListedPaths = {
listedRegistration: (userUuid: Uuid) => `/v1/users/${userUuid}/integrations/listed`,
}
export const Paths = {
v1: {
...FilesPaths,
...ItemsPaths,
...ListedPaths,
...SettingsPaths,
...SubscriptionPaths,
...UserPaths,
},
v2: {
...SubscriptionPathsV2,
...UserPathsV2,
},
}

View File

@@ -0,0 +1,30 @@
import { InternalEventBusInterface } from '@standardnotes/services'
import { StorageKey, DiskStorageService } from '@Lib/index'
import { SNWebSocketsService } from './WebsocketsService'
describe('webSocketsService', () => {
const webSocketUrl = ''
let storageService: DiskStorageService
let internalEventBus: InternalEventBusInterface
const createService = () => {
return new SNWebSocketsService(storageService, webSocketUrl, internalEventBus)
}
beforeEach(() => {
storageService = {} as jest.Mocked<DiskStorageService>
storageService.setValue = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
})
describe('setWebSocketUrl()', () => {
it('saves url in local storage', async () => {
const webSocketUrl = 'wss://test-websocket'
await createService().setWebSocketUrl(webSocketUrl)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.WebSocketUrl, webSocketUrl)
})
})
})

View File

@@ -0,0 +1,67 @@
import { UserRolesChangedEvent } from '@standardnotes/domain-events'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { AbstractService, InternalEventBusInterface, StorageKey } from '@standardnotes/services'
export enum WebSocketsServiceEvent {
UserRoleMessageReceived = 'WebSocketMessageReceived',
}
export class SNWebSocketsService extends AbstractService<WebSocketsServiceEvent, UserRolesChangedEvent> {
private webSocket?: WebSocket
constructor(
private storageService: DiskStorageService,
private webSocketUrl: string | undefined,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public setWebSocketUrl(url: string | undefined): void {
this.webSocketUrl = url
this.storageService.setValue(StorageKey.WebSocketUrl, url)
}
public loadWebSocketUrl(): void {
const storedValue = this.storageService.getValue<string | undefined>(StorageKey.WebSocketUrl)
this.webSocketUrl =
storedValue ||
this.webSocketUrl ||
(
window as {
_websocket_url?: string
}
)._websocket_url
}
public startWebSocketConnection(authToken: string): void {
if (this.webSocketUrl) {
try {
this.webSocket = new WebSocket(`${this.webSocketUrl}?authToken=Bearer+${authToken}`)
this.webSocket.onmessage = this.onWebSocketMessage.bind(this)
this.webSocket.onclose = this.onWebSocketClose.bind(this)
} catch (e) {
console.error('Error starting WebSocket connection', e)
}
}
}
public closeWebSocketConnection(): void {
this.webSocket?.close()
}
private onWebSocketMessage(event: MessageEvent) {
const eventData: UserRolesChangedEvent = JSON.parse(event.data)
void this.notifyEvent(WebSocketsServiceEvent.UserRoleMessageReceived, eventData)
}
private onWebSocketClose() {
this.webSocket = undefined
}
override deinit(): void {
super.deinit()
;(this.storageService as unknown) = undefined
this.closeWebSocketConnection()
}
}

View File

@@ -0,0 +1,7 @@
export * from './ApiService'
export * from './HttpService'
export * from './Messages'
export * from './Paths'
export * from '../Session/Sessions/Session'
export * from '../Session/SessionManager'
export * from './WebsocketsService'

View File

@@ -0,0 +1,75 @@
import { ApplicationEvent } from '@Lib/Application/Event'
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services'
import { SNApplication } from '../../Application/Application'
export class ApplicationService extends AbstractService {
private unsubApp!: () => void
constructor(protected application: SNApplication, protected override internalEventBus: InternalEventBusInterface) {
super(internalEventBus)
this.addAppEventObserverAfterSubclassesFinishConstructing()
}
override deinit() {
;(this.application as unknown) = undefined
this.unsubApp()
;(this.unsubApp as unknown) = undefined
super.deinit()
}
addAppEventObserverAfterSubclassesFinishConstructing() {
setTimeout(() => {
this.addAppEventObserver()
}, 0)
}
addAppEventObserver() {
if (this.application.isStarted()) {
void this.onAppStart()
}
if (this.application.isLaunched()) {
void this.onAppLaunch()
}
this.unsubApp = this.application.addEventObserver(async (event: ApplicationEvent) => {
await this.onAppEvent(event)
if (event === ApplicationEvent.Started) {
void this.onAppStart()
} else if (event === ApplicationEvent.Launched) {
void this.onAppLaunch()
} else if (event === ApplicationEvent.CompletedFullSync) {
this.onAppFullSync()
} else if (event === ApplicationEvent.CompletedIncrementalSync) {
this.onAppIncrementalSync()
} else if (event === ApplicationEvent.KeyStatusChanged) {
void this.onAppKeyChange()
}
})
}
async onAppEvent(_event: ApplicationEvent) {
/** Optional override */
}
async onAppStart() {
/** Optional override */
}
async onAppLaunch() {
/** Optional override */
}
async onAppKeyChange() {
/** Optional override */
}
onAppIncrementalSync() {
/** Optional override */
}
onAppFullSync() {
/** Optional override */
}
}

View File

@@ -0,0 +1,112 @@
import { ChallengeModalTitle, ChallengeStrings } from '../Api/Messages'
import { assertUnreachable } from '@standardnotes/utils'
import { ChallengeValidation, ChallengeReason, ChallengeInterface, ChallengePrompt } from '@standardnotes/services'
/**
* A challenge is a stateless description of what the client needs to provide
* in order to proceed.
*/
export class Challenge implements ChallengeInterface {
public readonly id = Math.random()
constructor(
public readonly prompts: ChallengePrompt[],
public readonly reason: ChallengeReason,
public readonly cancelable: boolean,
public readonly _heading?: string,
public readonly _subheading?: string,
) {
Object.freeze(this)
}
/** Outside of the modal, this is the title of the modal itself */
get modalTitle(): string {
switch (this.reason) {
case ChallengeReason.Migration:
return ChallengeModalTitle.Migration
default:
return ChallengeModalTitle.Generic
}
}
/** Inside of the modal, this is the H1 */
get heading(): string | undefined {
if (this._heading) {
return this._heading
} else {
switch (this.reason) {
case ChallengeReason.ApplicationUnlock:
return ChallengeStrings.UnlockApplication
case ChallengeReason.Migration:
return ChallengeStrings.EnterLocalPasscode
case ChallengeReason.ResaveRootKey:
return ChallengeStrings.EnterPasscodeForRootResave
case ChallengeReason.ProtocolUpgrade:
return ChallengeStrings.EnterCredentialsForProtocolUpgrade
case ChallengeReason.AccessProtectedNote:
return ChallengeStrings.NoteAccess
case ChallengeReason.AccessProtectedFile:
return ChallengeStrings.FileAccess
case ChallengeReason.ImportFile:
return ChallengeStrings.ImportFile
case ChallengeReason.AddPasscode:
return ChallengeStrings.AddPasscode
case ChallengeReason.RemovePasscode:
return ChallengeStrings.RemovePasscode
case ChallengeReason.ChangePasscode:
return ChallengeStrings.ChangePasscode
case ChallengeReason.ChangeAutolockInterval:
return ChallengeStrings.ChangeAutolockInterval
case ChallengeReason.CreateDecryptedBackupWithProtectedItems:
return ChallengeStrings.EnterCredentialsForDecryptedBackupDownload
case ChallengeReason.RevokeSession:
return ChallengeStrings.RevokeSession
case ChallengeReason.DecryptEncryptedFile:
return ChallengeStrings.DecryptEncryptedFile
case ChallengeReason.ExportBackup:
return ChallengeStrings.ExportBackup
case ChallengeReason.DisableBiometrics:
return ChallengeStrings.DisableBiometrics
case ChallengeReason.UnprotectNote:
return ChallengeStrings.UnprotectNote
case ChallengeReason.UnprotectFile:
return ChallengeStrings.UnprotectFile
case ChallengeReason.SearchProtectedNotesText:
return ChallengeStrings.SearchProtectedNotesText
case ChallengeReason.SelectProtectedNote:
return ChallengeStrings.SelectProtectedNote
case ChallengeReason.DisableMfa:
return ChallengeStrings.DisableMfa
case ChallengeReason.DeleteAccount:
return ChallengeStrings.DeleteAccount
case ChallengeReason.Custom:
return ''
default:
return assertUnreachable(this.reason)
}
}
}
/** Inside of the modal, this is the H2 */
get subheading(): string | undefined {
if (this._subheading) {
return this._subheading
}
switch (this.reason) {
case ChallengeReason.Migration:
return ChallengeStrings.EnterPasscodeForMigration
default:
return undefined
}
}
hasPromptForValidationType(type: ChallengeValidation): boolean {
for (const prompt of this.prompts) {
if (prompt.validation === type) {
return true
}
}
return false
}
}

View File

@@ -0,0 +1,108 @@
import { Challenge } from './Challenge'
import { ChallengeResponse } from './ChallengeResponse'
import { removeFromArray } from '@standardnotes/utils'
import { ValueCallback } from './ChallengeService'
import { ChallengeValue, ChallengeArtifacts } from '@standardnotes/services'
/**
* A challenge operation stores user-submitted values and callbacks.
* When its values are updated, it will trigger the associated callbacks (valid/invalid/complete)
*/
export class ChallengeOperation {
private nonvalidatedValues: ChallengeValue[] = []
private validValues: ChallengeValue[] = []
private invalidValues: ChallengeValue[] = []
private artifacts: ChallengeArtifacts = {}
constructor(
public challenge: Challenge,
public onValidValue: ValueCallback,
public onInvalidValue: ValueCallback,
public onNonvalidatedSubmit: (response: ChallengeResponse) => void,
public onComplete: (response: ChallengeResponse) => void,
public onCancel: () => void,
) {}
deinit() {
;(this.challenge as unknown) = undefined
;(this.onValidValue as unknown) = undefined
;(this.onInvalidValue as unknown) = undefined
;(this.onNonvalidatedSubmit as unknown) = undefined
;(this.onComplete as unknown) = undefined
;(this.onCancel as unknown) = undefined
;(this.nonvalidatedValues as unknown) = undefined
;(this.validValues as unknown) = undefined
;(this.invalidValues as unknown) = undefined
;(this.artifacts as unknown) = undefined
}
/**
* Mark this challenge as complete, triggering the resolve function,
* as well as notifying the client
*/
public complete(response?: ChallengeResponse) {
if (!response) {
response = new ChallengeResponse(this.challenge, this.validValues, this.artifacts)
}
this.onComplete?.(response)
}
public nonvalidatedSubmit() {
const response = new ChallengeResponse(this.challenge, this.nonvalidatedValues.slice(), this.artifacts)
this.onNonvalidatedSubmit?.(response)
/** Reset values */
this.nonvalidatedValues = []
}
public cancel() {
this.onCancel?.()
}
/**
* @returns Returns true if the challenge has received all valid responses
*/
public isFinished() {
return this.validValues.length === this.challenge.prompts.length
}
private nonvalidatedPrompts() {
return this.challenge.prompts.filter((p) => !p.validates)
}
public addNonvalidatedValue(value: ChallengeValue) {
const valuesArray = this.nonvalidatedValues
const matching = valuesArray.find((v) => v.prompt.id === value.prompt.id)
if (matching) {
removeFromArray(valuesArray, matching)
}
valuesArray.push(value)
if (this.nonvalidatedValues.length === this.nonvalidatedPrompts().length) {
this.nonvalidatedSubmit()
}
}
/**
* Sets the values validation status, as well as handles subsequent actions,
* such as completing the operation if all valid values are supplied, as well as
* notifying the client of this new value's validation status.
*/
public setValueStatus(value: ChallengeValue, valid: boolean, artifacts?: ChallengeArtifacts) {
const valuesArray = valid ? this.validValues : this.invalidValues
const matching = valuesArray.find((v) => v.prompt.validation === value.prompt.validation)
if (matching) {
removeFromArray(valuesArray, matching)
}
valuesArray.push(value)
Object.assign(this.artifacts, artifacts)
if (this.isFinished()) {
this.complete()
} else {
if (valid) {
this.onValidValue?.(value)
} else {
this.onInvalidValue?.(value)
}
}
}
}

View File

@@ -0,0 +1,33 @@
import { isNullOrUndefined } from '@standardnotes/utils'
import { Challenge } from './Challenge'
import {
ChallengeResponseInterface,
ChallengeValidation,
ChallengeValue,
ChallengeArtifacts,
} from '@standardnotes/services'
export class ChallengeResponse implements ChallengeResponseInterface {
constructor(
public readonly challenge: Challenge,
public readonly values: ChallengeValue[],
public readonly artifacts?: ChallengeArtifacts,
) {
Object.freeze(this)
}
getValueForType(type: ChallengeValidation): ChallengeValue {
const value = this.values.find((value) => value.prompt.validation === type)
if (isNullOrUndefined(value)) {
throw Error('Could not find value for validation type ' + type)
}
return value
}
getDefaultValue(): ChallengeValue {
if (this.values.length > 1) {
throw Error('Attempting to retrieve default response value when more than one value exists')
}
return this.values[0]
}
}

View File

@@ -0,0 +1,300 @@
import { RootKeyInterface } from '@standardnotes/models'
import { EncryptionService } from '@standardnotes/encryption'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { removeFromArray } from '@standardnotes/utils'
import { isValidProtectionSessionLength } from '../Protection/ProtectionService'
import {
AbstractService,
ChallengeServiceInterface,
InternalEventBusInterface,
ChallengeArtifacts,
ChallengeReason,
ChallengeValidation,
ChallengeValue,
ChallengeInterface,
ChallengePromptInterface,
ChallengePrompt,
} from '@standardnotes/services'
import { ChallengeResponse } from './ChallengeResponse'
import { ChallengeOperation } from './ChallengeOperation'
import { Challenge } from './Challenge'
type ChallengeValidationResponse = {
valid: boolean
artifacts?: ChallengeArtifacts
}
export type ValueCallback = (value: ChallengeValue) => void
export type ChallengeObserver = {
onValidValue?: ValueCallback
onInvalidValue?: ValueCallback
onNonvalidatedSubmit?: (response: ChallengeResponse) => void
onComplete?: (response: ChallengeResponse) => void
onCancel?: () => void
}
const clearChallengeObserver = (observer: ChallengeObserver) => {
observer.onCancel = undefined
observer.onComplete = undefined
observer.onValidValue = undefined
observer.onInvalidValue = undefined
observer.onNonvalidatedSubmit = undefined
}
/**
* The challenge service creates, updates and keeps track of running challenge operations.
*/
export class ChallengeService extends AbstractService implements ChallengeServiceInterface {
private challengeOperations: Record<string, ChallengeOperation> = {}
public sendChallenge!: (challenge: Challenge) => void
private challengeObservers: Record<string, ChallengeObserver[]> = {}
constructor(
private storageService: DiskStorageService,
private protocolService: EncryptionService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public override deinit() {
;(this.storageService as unknown) = undefined
;(this.protocolService as unknown) = undefined
;(this.sendChallenge as unknown) = undefined
;(this.challengeOperations as unknown) = undefined
;(this.challengeObservers as unknown) = undefined
super.deinit()
}
public promptForChallengeResponse(challenge: Challenge): Promise<ChallengeResponse | undefined> {
return new Promise<ChallengeResponse | undefined>((resolve) => {
this.createOrGetChallengeOperation(challenge, resolve)
this.sendChallenge(challenge)
})
}
public createChallenge(
prompts: ChallengePromptInterface[],
reason: ChallengeReason,
cancelable: boolean,
heading?: string,
subheading?: string,
): ChallengeInterface {
return new Challenge(prompts, reason, cancelable, heading, subheading)
}
public async validateChallengeValue(value: ChallengeValue): Promise<ChallengeValidationResponse> {
switch (value.prompt.validation) {
case ChallengeValidation.LocalPasscode:
return this.protocolService.validatePasscode(value.value as string)
case ChallengeValidation.AccountPassword:
return this.protocolService.validateAccountPassword(value.value as string)
case ChallengeValidation.Biometric:
return { valid: value.value === true }
case ChallengeValidation.ProtectionSessionDuration:
return { valid: isValidProtectionSessionLength(value.value) }
default:
throw Error(`Unhandled validation mode ${value.prompt.validation}`)
}
}
public async promptForCorrectPasscode(reason: ChallengeReason): Promise<string | undefined> {
const challenge = new Challenge([new ChallengePrompt(ChallengeValidation.LocalPasscode)], reason, true)
const response = await this.promptForChallengeResponse(challenge)
if (!response) {
return undefined
}
const value = response.getValueForType(ChallengeValidation.LocalPasscode)
return value.value as string
}
/**
* Returns the wrapping key for operations that require resaving the root key
* (changing the account password, signing in, registering, or upgrading protocol)
* Returns empty object if no passcode is configured.
* Otherwise returns {cancled: true} if the operation is canceled, or
* {wrappingKey} with the result.
* @param passcode - If the consumer already has access to the passcode,
* they can pass it here so that the user is not prompted again.
*/
async getWrappingKeyIfApplicable(passcode?: string): Promise<
| {
canceled?: undefined
wrappingKey?: undefined
}
| {
canceled: boolean
wrappingKey?: undefined
}
| {
wrappingKey: RootKeyInterface
canceled?: undefined
}
> {
if (!this.protocolService.hasPasscode()) {
return {}
}
if (!passcode) {
passcode = await this.promptForCorrectPasscode(ChallengeReason.ResaveRootKey)
if (!passcode) {
return { canceled: true }
}
}
const wrappingKey = await this.protocolService.computeWrappingKey(passcode)
return { wrappingKey }
}
public isPasscodeLocked() {
return this.protocolService.isPasscodeLocked()
}
public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver) {
const observers = this.challengeObservers[challenge.id] || []
observers.push(observer)
this.challengeObservers[challenge.id] = observers
return () => {
clearChallengeObserver(observer)
removeFromArray(observers, observer)
}
}
private createOrGetChallengeOperation(
challenge: Challenge,
resolve: (response: ChallengeResponse | undefined) => void,
): ChallengeOperation {
let operation = this.getChallengeOperation(challenge)
if (!operation) {
operation = new ChallengeOperation(
challenge,
(value: ChallengeValue) => {
this.onChallengeValidValue(challenge, value)
},
(value: ChallengeValue) => {
this.onChallengeInvalidValue(challenge, value)
},
(response: ChallengeResponse) => {
this.onChallengeNonvalidatedSubmit(challenge, response)
resolve(response)
},
(response: ChallengeResponse) => {
this.onChallengeComplete(challenge, response)
resolve(response)
},
() => {
this.onChallengeCancel(challenge)
resolve(undefined)
},
)
this.challengeOperations[challenge.id] = operation
}
return operation
}
private performOnObservers(challenge: Challenge, perform: (observer: ChallengeObserver) => void) {
const observers = this.challengeObservers[challenge.id] || []
for (const observer of observers) {
perform(observer)
}
}
private onChallengeValidValue(challenge: Challenge, value: ChallengeValue) {
this.performOnObservers(challenge, (observer) => {
observer.onValidValue?.(value)
})
}
private onChallengeInvalidValue(challenge: Challenge, value: ChallengeValue) {
this.performOnObservers(challenge, (observer) => {
observer.onInvalidValue?.(value)
})
}
private onChallengeNonvalidatedSubmit(challenge: Challenge, response: ChallengeResponse) {
this.performOnObservers(challenge, (observer) => {
observer.onNonvalidatedSubmit?.(response)
})
}
private onChallengeComplete(challenge: Challenge, response: ChallengeResponse) {
this.performOnObservers(challenge, (observer) => {
observer.onComplete?.(response)
})
}
private onChallengeCancel(challenge: Challenge) {
this.performOnObservers(challenge, (observer) => {
observer.onCancel?.()
})
}
private getChallengeOperation(challenge: Challenge) {
return this.challengeOperations[challenge.id]
}
private deleteChallengeOperation(operation: ChallengeOperation) {
const challenge = operation.challenge
operation.deinit()
delete this.challengeOperations[challenge.id]
}
public cancelChallenge(challenge: Challenge) {
const operation = this.challengeOperations[challenge.id]
operation.cancel()
this.deleteChallengeOperation(operation)
}
public completeChallenge(challenge: Challenge): void {
const operation = this.challengeOperations[challenge.id]
operation.complete()
this.deleteChallengeOperation(operation)
}
public async submitValuesForChallenge(challenge: Challenge, values: ChallengeValue[]) {
if (values.length === 0) {
throw Error('Attempting to submit 0 values for challenge')
}
for (const value of values) {
if (!value.prompt.validates) {
const operation = this.getChallengeOperation(challenge)
operation.addNonvalidatedValue(value)
} else {
const { valid, artifacts } = await this.validateChallengeValue(value)
this.setValidationStatusForChallenge(challenge, value, valid, artifacts)
}
}
}
public setValidationStatusForChallenge(
challenge: Challenge,
value: ChallengeValue,
valid: boolean,
artifacts?: ChallengeArtifacts,
) {
const operation = this.getChallengeOperation(challenge)
operation.setValueStatus(value, valid, artifacts)
if (operation.isFinished()) {
this.deleteChallengeOperation(operation)
const observers = this.challengeObservers[challenge.id]
observers.forEach(clearChallengeObserver)
observers.length = 0
delete this.challengeObservers[challenge.id]
}
}
}

View File

@@ -0,0 +1,4 @@
export * from './Challenge'
export * from './ChallengeOperation'
export * from './ChallengeResponse'
export * from './ChallengeService'

View File

@@ -0,0 +1,368 @@
/**
* @jest-environment jsdom
*/
import { SNPreferencesService } from '../Preferences/PreferencesService'
import {
ComponentAction,
ComponentPermission,
FeatureDescription,
FindNativeFeature,
FeatureIdentifier,
} from '@standardnotes/features'
import { DesktopManagerInterface } from '@Lib/Services/ComponentManager/Types'
import { ContentType } from '@standardnotes/common'
import { GenericItem, SNComponent } from '@standardnotes/models'
import { InternalEventBusInterface, Environment, Platform, AlertService } from '@standardnotes/services'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { SNComponentManager } from './ComponentManager'
import { SNSyncService } from '../Sync/SyncService'
describe('featuresService', () => {
let itemManager: ItemManager
let featureService: SNFeaturesService
let alertService: AlertService
let syncService: SNSyncService
let prefsService: SNPreferencesService
let internalEventBus: InternalEventBusInterface
const desktopExtHost = 'http://localhost:123'
const createManager = (environment: Environment, platform: Platform) => {
const desktopManager: DesktopManagerInterface = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
syncComponentsInstallation() {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
registerUpdateObserver() {},
getExtServerHost() {
return desktopExtHost
},
}
const manager = new SNComponentManager(
itemManager,
syncService,
featureService,
prefsService,
alertService,
environment,
platform,
internalEventBus,
)
manager.setDesktopManager(desktopManager)
return manager
}
beforeEach(() => {
syncService = {} as jest.Mocked<SNSyncService>
syncService.sync = jest.fn()
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue([])
itemManager.createItem = jest.fn()
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<GenericItem>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
featureService = {} as jest.Mocked<SNFeaturesService>
prefsService = {} as jest.Mocked<SNPreferencesService>
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn()
alertService.alert = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
})
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {
return new SNComponent({
uuid: '789',
content_type: ContentType.Component,
content: {
package_info: {
hosted_url: 'https://example.com/component',
identifier: identifier || FeatureIdentifier.PlusEditor,
file_type: file_type ?? 'html',
valid_until: new Date(),
},
},
} as never)
}
const deprecatedComponent = () => {
return new SNComponent({
uuid: '789',
content_type: ContentType.Component,
content: {
package_info: {
hosted_url: 'https://example.com/component',
identifier: FeatureIdentifier.DeprecatedFileSafe,
valid_until: new Date(),
},
},
} as never)
}
const thirdPartyComponent = () => {
return new SNComponent({
uuid: '789',
content_type: ContentType.Component,
content: {
local_url: 'sn://Extensions/non-native-identifier/dist/index.html',
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier',
valid_until: new Date(),
},
},
} as never)
}
describe('permissions', () => {
it('editor should be able to to stream single note', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamContextItem,
content_types: [ContentType.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownVisualEditor), permissions),
).toEqual(true)
})
it('no extension should be able to stream multiple notes', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
})
it('no extension should be able to stream multiple tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.Tag],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
})
it('no extension should be able to stream multiple notes or tags', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.Tag, ContentType.Note],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false)
})
it('some valid and some invalid permissions should still return invalid permissions', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [ContentType.Tag, ContentType.FilesafeFileMetadata],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
).toEqual(false)
})
it('filesafe should be able to stream its files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.FilesafeFileMetadata,
ContentType.FilesafeCredentials,
ContentType.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions),
).toEqual(true)
})
it('bold editor should be able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.FilesafeFileMetadata,
ContentType.FilesafeCredentials,
ContentType.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(
manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedBoldEditor), permissions),
).toEqual(true)
})
it('non bold editor should not able to stream filesafe files', () => {
const permissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: [
ContentType.FilesafeFileMetadata,
ContentType.FilesafeCredentials,
ContentType.FilesafeIntegration,
],
},
]
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual(
false,
)
})
})
describe('urlForComponent', () => {
describe('desktop', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = nativeComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier)
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
})
it('returns native path for deprecated native component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = deprecatedComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier)
expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`)
})
it('returns nonnative path for third party component', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = thirdPartyComponent()
const url = manager.urlForComponent(component)
expect(url).toEqual(`${desktopExtHost}/Extensions/${component.identifier}/dist/index.html`)
})
it('returns hosted url for third party component with no local_url', () => {
const manager = createManager(Environment.Desktop, Platform.MacDesktop)
const component = new SNComponent({
uuid: '789',
content_type: ContentType.Component,
content: {
hosted_url: 'https://example.com/component',
package_info: {
identifier: 'non-native-identifier',
valid_until: new Date(),
},
},
} as never)
const url = manager.urlForComponent(component)
expect(url).toEqual('https://example.com/component')
})
})
describe('web', () => {
it('returns native path for native component', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeComponent()
const url = manager.urlForComponent(component)
const feature = FindNativeFeature(component.identifier) as FeatureDescription
expect(url).toEqual(`http://localhost/components/assets/${component.identifier}/${feature.index_path}`)
})
it('returns hosted path for third party component', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = thirdPartyComponent()
const url = manager.urlForComponent(component)
expect(url).toEqual(component.hosted_url)
})
})
})
describe('editor change alert', () => {
it('should not require alert switching from plain editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeComponent()
const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to plain editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const component = nativeComponent()
const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from a markdown editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeComponent()
const markdownEditor = nativeComponent(undefined, 'md')
const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching to a markdown editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeComponent()
const markdownEditor = nativeComponent(undefined, 'md')
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor)
expect(requiresAlert).toBe(false)
})
it('should not require alert switching from & to a html editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeComponent()
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor)
expect(requiresAlert).toBe(false)
})
it('should require alert switching from a html editor to custom editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeComponent()
const customEditor = nativeComponent(undefined, 'json')
const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to html editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const htmlEditor = nativeComponent()
const customEditor = nativeComponent(undefined, 'json')
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor)
expect(requiresAlert).toBe(true)
})
it('should require alert switching from a custom editor to custom editor', () => {
const manager = createManager(Environment.Web, Platform.MacWeb)
const customEditor = nativeComponent(undefined, 'json')
const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor)
expect(requiresAlert).toBe(true)
})
})
})

View File

@@ -0,0 +1,673 @@
import { AllowedBatchStreaming } from './Types'
import { SNPreferencesService } from '../Preferences/PreferencesService'
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
import { ContentType, DisplayStringForContentType } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNNote, SNTheme, SNComponent, ComponentMutator, PayloadEmitSource } from '@standardnotes/models'
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
import find from 'lodash/find'
import uniq from 'lodash/uniq'
import { ComponentArea, ComponentAction, ComponentPermission, FindNativeFeature } from '@standardnotes/features'
import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils'
import { UuidString } from '@Lib/Types/UuidString'
import {
PermissionDialog,
DesktopManagerInterface,
AllowedBatchContentTypes,
} from '@Lib/Services/ComponentManager/Types'
import { ActionObserver, ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer'
import {
AbstractService,
InternalEventBusInterface,
Environment,
Platform,
AlertService,
} from '@standardnotes/services'
const DESKTOP_URL_PREFIX = 'sn://'
const LOCAL_HOST = 'localhost'
const CUSTOM_LOCAL_HOST = 'sn.local'
const ANDROID_LOCAL_HOST = '10.0.2.2'
declare global {
interface Window {
/** IE Handlers */
attachEvent(event: string, listener: EventListener): boolean
detachEvent(event: string, listener: EventListener): void
}
}
export enum ComponentManagerEvent {
ViewerDidFocus = 'ViewerDidFocus',
}
export type EventData = {
componentViewer?: ComponentViewer
}
/**
* Responsible for orchestrating component functionality, including editors, themes,
* and other components. The component manager primarily deals with iframes, and orchestrates
* sending and receiving messages to and from frames via the postMessage API.
*/
export class SNComponentManager extends AbstractService<ComponentManagerEvent, EventData> {
private desktopManager?: DesktopManagerInterface
private viewers: ComponentViewer[] = []
private removeItemObserver!: () => void
private permissionDialogs: PermissionDialog[] = []
constructor(
private itemManager: ItemManager,
private syncService: SNSyncService,
private featuresService: SNFeaturesService,
private preferencesSerivce: SNPreferencesService,
protected alertService: AlertService,
private environment: Environment,
private platform: Platform,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.loggingEnabled = false
this.addItemObserver()
/* On mobile, events listeners are handled by a respective component */
if (environment !== Environment.Mobile) {
window.addEventListener
? window.addEventListener('focus', this.detectFocusChange, true)
: window.attachEvent('onfocusout', this.detectFocusChange)
window.addEventListener
? window.addEventListener('blur', this.detectFocusChange, true)
: window.attachEvent('onblur', this.detectFocusChange)
window.addEventListener('message', this.onWindowMessage, true)
}
}
get isDesktop(): boolean {
return this.environment === Environment.Desktop
}
get isMobile(): boolean {
return this.environment === Environment.Mobile
}
get components(): SNComponent[] {
return this.itemManager.getDisplayableComponents()
}
componentsForArea(area: ComponentArea): SNComponent[] {
return this.components.filter((component) => {
return component.area === area
})
}
override deinit(): void {
super.deinit()
for (const viewer of this.viewers) {
viewer.destroy()
}
this.viewers.length = 0
this.permissionDialogs.length = 0
this.desktopManager = undefined
;(this.itemManager as unknown) = undefined
;(this.featuresService as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.preferencesSerivce as unknown) = undefined
this.removeItemObserver?.()
;(this.removeItemObserver as unknown) = undefined
if (window && !this.isMobile) {
window.removeEventListener('focus', this.detectFocusChange, true)
window.removeEventListener('blur', this.detectFocusChange, true)
window.removeEventListener('message', this.onWindowMessage, true)
}
;(this.detectFocusChange as unknown) = undefined
;(this.onWindowMessage as unknown) = undefined
}
public createComponentViewer(
component: SNComponent,
contextItem?: UuidString,
actionObserver?: ActionObserver,
urlOverride?: string,
): ComponentViewer {
const viewer = new ComponentViewer(
component,
this.itemManager,
this.syncService,
this.alertService,
this.preferencesSerivce,
this.featuresService,
this.environment,
this.platform,
{
runWithPermissions: this.runWithPermissions.bind(this),
urlsForActiveThemes: this.urlsForActiveThemes.bind(this),
},
urlOverride || this.urlForComponent(component),
contextItem,
actionObserver,
)
this.viewers.push(viewer)
return viewer
}
public destroyComponentViewer(viewer: ComponentViewer): void {
viewer.destroy()
removeFromArray(this.viewers, viewer)
}
setDesktopManager(desktopManager: DesktopManagerInterface): void {
this.desktopManager = desktopManager
this.configureForDesktop()
}
handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void {
const acceptableSources = [
PayloadEmitSource.LocalChanged,
PayloadEmitSource.RemoteRetrieved,
PayloadEmitSource.LocalDatabaseLoaded,
PayloadEmitSource.LocalInserted,
]
if (components.length === 0 || !acceptableSources.includes(source)) {
return
}
if (this.isDesktop) {
const thirdPartyComponents = components.filter((component) => {
const nativeFeature = FindNativeFeature(component.identifier)
return nativeFeature ? false : true
})
if (thirdPartyComponents.length > 0) {
this.desktopManager?.syncComponentsInstallation(thirdPartyComponents)
}
}
const themes = components.filter((c) => c.isTheme())
if (themes.length > 0) {
this.postActiveThemesToAllViewers()
}
}
addItemObserver(): void {
this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
[ContentType.Component, ContentType.Theme],
({ changed, inserted, source }) => {
const items = [...changed, ...inserted]
this.handleChangedComponents(items, source)
},
)
}
detectFocusChange = (): void => {
const activeIframes = this.allComponentIframes()
for (const iframe of activeIframes) {
if (document.activeElement === iframe) {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const viewer = this.findComponentViewer(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
iframe.dataset.componentViewerId!,
)!
void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, {
componentViewer: viewer,
})
})
return
}
}
}
onWindowMessage = (event: MessageEvent): void => {
/** Make sure this message is for us */
if (event.data.sessionKey) {
this.log('Component manager received message', event.data)
this.componentViewerForSessionKey(event.data.sessionKey)?.handleMessage(event.data)
}
}
configureForDesktop(): void {
this.desktopManager?.registerUpdateObserver((component: SNComponent) => {
/* Reload theme if active */
if (component.active && component.isTheme()) {
this.postActiveThemesToAllViewers()
}
})
}
postActiveThemesToAllViewers(): void {
for (const viewer of this.viewers) {
viewer.postActiveThemes()
}
}
getActiveThemes(): SNTheme[] {
if (this.environment === Environment.Mobile) {
throw Error('getActiveThemes must be handled separately by mobile')
}
return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
return theme.active
}) as SNTheme[]
}
urlForComponent(component: SNComponent): string | undefined {
const platformSupportsOfflineOnly = this.isDesktop
if (component.offlineOnly && !platformSupportsOfflineOnly) {
return undefined
}
const nativeFeature = FindNativeFeature(component.identifier)
if (this.isDesktop) {
assert(this.desktopManager)
if (nativeFeature) {
return `${this.desktopManager.getExtServerHost()}/components/${component.identifier}/${
nativeFeature.index_path
}`
} else if (component.local_url) {
return component.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/')
} else {
return component.hosted_url || component.legacy_url
}
}
const isWeb = this.environment === Environment.Web
if (nativeFeature) {
if (!isWeb) {
throw Error('Mobile must override urlForComponent to handle native paths')
}
return `${window.location.origin}/components/assets/${component.identifier}/${nativeFeature.index_path}`
}
let url = component.hosted_url || component.legacy_url
if (!url) {
return undefined
}
if (this.isMobile) {
const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST
url = url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement)
}
return url
}
urlsForActiveThemes(): string[] {
const themes = this.getActiveThemes()
const urls = []
for (const theme of themes) {
const url = this.urlForComponent(theme)
if (url) {
urls.push(url)
}
}
return urls
}
private findComponent(uuid: UuidString): SNComponent | undefined {
return this.itemManager.findItem<SNComponent>(uuid)
}
findComponentViewer(identifier: string): ComponentViewer | undefined {
return this.viewers.find((viewer) => viewer.identifier === identifier)
}
componentViewerForSessionKey(key: string): ComponentViewer | undefined {
return this.viewers.find((viewer) => viewer.sessionKey === key)
}
areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean {
for (const permission of permissions) {
if (permission.name === ComponentAction.StreamItems) {
if (!AllowedBatchStreaming.includes(component.identifier)) {
return false
}
const hasNonAllowedBatchPermission = permission.content_types?.some(
(type) => !AllowedBatchContentTypes.includes(type),
)
if (hasNonAllowedBatchPermission) {
return false
}
}
}
return true
}
runWithPermissions(
componentUuid: UuidString,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
): void {
const component = this.findComponent(componentUuid)
if (!component) {
void this.alertService.alert(
`Unable to find component with ID ${componentUuid}. Please restart the app and try again.`,
'An unexpected error occurred',
)
return
}
if (!this.areRequestedPermissionsValid(component, requiredPermissions)) {
console.error('Component is requesting invalid permissions', componentUuid, requiredPermissions)
return
}
const nativeFeature = FindNativeFeature(component.identifier)
const acquiredPermissions = nativeFeature?.component_permissions || component.permissions
/* Make copy as not to mutate input values */
requiredPermissions = Copy(requiredPermissions) as ComponentPermission[]
for (const required of requiredPermissions.slice()) {
/* Remove anything we already have */
const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name)
if (!respectiveAcquired) {
continue
}
/* We now match on name, lets substract from required.content_types anything we have in acquired. */
const requiredContentTypes = required.content_types
if (!requiredContentTypes) {
/* If this permission does not require any content types (i.e stream-context-item)
then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */
filterFromArray(requiredPermissions, required)
continue
}
for (const acquiredContentType of respectiveAcquired.content_types!) {
removeFromArray(requiredContentTypes, acquiredContentType)
}
if (requiredContentTypes.length === 0) {
/* We've removed all acquired and end up with zero, means we already have all these permissions */
filterFromArray(requiredPermissions, required)
}
}
if (requiredPermissions.length > 0) {
this.promptForPermissionsWithAngularAsyncRendering(
component,
requiredPermissions,
// eslint-disable-next-line @typescript-eslint/require-await
async (approved) => {
if (approved) {
runFunction()
}
},
)
} else {
runFunction()
}
}
promptForPermissionsWithAngularAsyncRendering(
component: SNComponent,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
setTimeout(() => {
this.promptForPermissions(component, permissions, callback)
})
}
promptForPermissions(
component: SNComponent,
permissions: ComponentPermission[],
callback: (approved: boolean) => Promise<void>,
): void {
const params: PermissionDialog = {
component: component,
permissions: permissions,
permissionsString: this.permissionsStringForPermissions(permissions, component),
actionBlock: callback,
callback: async (approved: boolean) => {
const latestComponent = this.findComponent(component.uuid)
if (!latestComponent) {
return
}
if (approved) {
this.log('Changing component to expand permissions', component)
const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[]
for (const permission of permissions) {
const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name)
if (!matchingPermission) {
componentPermissions.push(permission)
} else {
/* Permission already exists, but content_types may have been expanded */
const contentTypes = matchingPermission.content_types || []
matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!))
}
}
await this.itemManager.changeItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.permissions = componentPermissions
})
void this.syncService.sync()
}
this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => {
/* Remove self */
if (pendingDialog === params) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
return false
}
const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => {
return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val)))
}
if (pendingDialog.component === component) {
/* remove pending dialogs that are encapsulated by already approved permissions, and run its function */
if (
pendingDialog.permissions === permissions ||
containsObjectSubset(permissions, pendingDialog.permissions)
) {
/* If approved, run the action block. Otherwise, if canceled, cancel any
pending ones as well, since the user was explicit in their intentions */
if (approved) {
pendingDialog.actionBlock && pendingDialog.actionBlock(approved)
}
return false
}
}
return true
})
if (this.permissionDialogs.length > 0) {
this.presentPermissionsDialog(this.permissionDialogs[0])
}
},
}
/**
* Since these calls are asyncronous, multiple dialogs may be requested at the same time.
* We only want to present one and trigger all callbacks based on one modal result
*/
const existingDialog = find(this.permissionDialogs, {
component: component,
})
this.permissionDialogs.push(params)
if (!existingDialog) {
this.presentPermissionsDialog(params)
} else {
this.log('Existing dialog, not presenting.')
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
presentPermissionsDialog(_dialog: PermissionDialog): void {
throw 'Must override SNComponentManager.presentPermissionsDialog'
}
async toggleTheme(uuid: UuidString): Promise<void> {
this.log('Toggling theme', uuid)
const theme = this.findComponent(uuid) as SNTheme
if (theme.active) {
await this.itemManager.changeComponent(theme, (mutator) => {
mutator.active = false
})
} else {
const activeThemes = this.getActiveThemes()
/* Activate current before deactivating others, so as not to flicker */
await this.itemManager.changeComponent(theme, (mutator) => {
mutator.active = true
})
/* Deactive currently active theme(s) if new theme is not layerable */
if (!theme.isLayerable()) {
await sleep(10)
for (const candidate of activeThemes) {
if (candidate && !candidate.isLayerable()) {
await this.itemManager.changeComponent(candidate, (mutator) => {
mutator.active = false
})
}
}
}
}
}
async toggleComponent(uuid: UuidString): Promise<void> {
this.log('Toggling component', uuid)
const component = this.findComponent(uuid)
if (!component) {
return
}
await this.itemManager.changeComponent(component, (mutator) => {
mutator.active = !(mutator.getItem() as SNComponent).active
})
}
isComponentActive(component: SNComponent): boolean {
return component.active
}
allComponentIframes(): HTMLIFrameElement[] {
if (this.isMobile) {
/**
* Retrieving all iframes is typically related to lifecycle management of
* non-editor components. So this function is not useful to mobile.
*/
return []
}
return Array.from(document.getElementsByTagName('iframe'))
}
iframeForComponentViewer(viewer: ComponentViewer): HTMLIFrameElement | undefined {
return viewer.getIframe()
}
editorForNote(note: SNNote): SNComponent | undefined {
const editors = this.componentsForArea(ComponentArea.Editor)
for (const editor of editors) {
if (editor.isExplicitlyEnabledForItem(note.uuid)) {
return editor
}
}
let defaultEditor
/* No editor found for note. Use default editor, if note does not prefer system editor */
if (this.isMobile) {
if (!note.mobilePrefersPlainEditor) {
defaultEditor = this.getDefaultEditor()
}
} else {
if (!note.prefersPlainEditor) {
defaultEditor = this.getDefaultEditor()
}
}
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
return defaultEditor
} else {
return undefined
}
}
getDefaultEditor(): SNComponent {
const editors = this.componentsForArea(ComponentArea.Editor)
if (this.isMobile) {
return editors.filter((e) => {
return e.isMobileDefault
})[0]
} else {
return editors.filter((e) => e.isDefaultEditor())[0]
}
}
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string {
if (permissions.length === 0) {
return '.'
}
let contentTypeStrings: string[] = []
let contextAreaStrings: string[] = []
permissions.forEach((permission) => {
switch (permission.name) {
case ComponentAction.StreamItems:
if (!permission.content_types) {
return
}
permission.content_types.forEach((contentType) => {
const desc = DisplayStringForContentType(contentType)
if (desc) {
contentTypeStrings.push(`${desc}s`)
} else {
contentTypeStrings.push(`items of type ${contentType}`)
}
})
break
case ComponentAction.StreamContextItem:
{
const componentAreaMapping = {
[ComponentArea.EditorStack]: 'working note',
[ComponentArea.Editor]: 'working note',
[ComponentArea.Themes]: 'Unknown',
}
contextAreaStrings.push(componentAreaMapping[component.area])
}
break
}
})
contentTypeStrings = uniq(contentTypeStrings)
contextAreaStrings = uniq(contextAreaStrings)
if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) {
return '.'
}
return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.'
}
doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean {
const isEitherPlainEditor = !from || !to
const isEitherMarkdown = from?.package_info.file_type === 'md' || to?.package_info.file_type === 'md'
const areBothHtml = from?.package_info.file_type === 'html' && to?.package_info.file_type === 'html'
if (isEitherPlainEditor || isEitherMarkdown || areBothHtml) {
return false
} else {
return true
}
}
async showEditorChangeAlert(): Promise<boolean> {
const shouldChangeEditor = await this.alertService.confirm(
'Doing so might result in minor formatting changes.',
"Are you sure you want to change this note's type?",
'Yes, change it',
)
return shouldChangeEditor
}
}

View File

@@ -0,0 +1,913 @@
import { SNPreferencesService } from '../Preferences/PreferencesService'
import { FeatureStatus, FeaturesEvent } from '@Lib/Services/Features'
import { Environment, Platform, AlertService } from '@standardnotes/services'
import { SNFeaturesService } from '@Lib/Services'
import {
SNComponent,
PrefKey,
NoteContent,
MutationType,
CreateDecryptedItemFromPayload,
DecryptedItemInterface,
DeletedItemInterface,
EncryptedItemInterface,
isDecryptedItem,
isNotEncryptedItem,
isNote,
CreateComponentRetrievedContextPayload,
createComponentCreatedContextPayload,
DecryptedPayload,
ItemContent,
ComponentDataDomain,
PayloadEmitSource,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import find from 'lodash/find'
import uniq from 'lodash/uniq'
import remove from 'lodash/remove'
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
import { environmentToString, platformToString } from '@Lib/Application/Platforms'
import {
ComponentMessage,
OutgoingItemMessagePayload,
MessageReply,
StreamItemsMessageData,
AllowedBatchContentTypes,
IncomingComponentItemPayload,
DeleteItemsMessageData,
MessageReplyData,
} from './Types'
import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { UuidString } from '@Lib/Types/UuidString'
import { ContentType } from '@standardnotes/common'
import {
isString,
extendArray,
Copy,
removeFromArray,
log,
nonSecureRandomIdentifier,
UuidGenerator,
Uuids,
sureSearchArray,
isNotUndefined,
} from '@standardnotes/utils'
import { MessageData } from '..'
type RunWithPermissionsCallback = (
componentUuid: UuidString,
requiredPermissions: ComponentPermission[],
runFunction: () => void,
) => void
type ComponentManagerFunctions = {
runWithPermissions: RunWithPermissionsCallback
urlsForActiveThemes: () => string[]
}
const ReadwriteActions = [
ComponentAction.SaveItems,
ComponentAction.AssociateItem,
ComponentAction.DeassociateItem,
ComponentAction.CreateItem,
ComponentAction.CreateItems,
ComponentAction.DeleteItems,
ComponentAction.SetComponentData,
]
export type ActionObserver = (action: ComponentAction, messageData: MessageData) => void
export enum ComponentViewerEvent {
FeatureStatusUpdated = 'FeatureStatusUpdated',
}
type EventObserver = (event: ComponentViewerEvent) => void
export enum ComponentViewerError {
OfflineRestricted = 'OfflineRestricted',
MissingUrl = 'MissingUrl',
}
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
export class ComponentViewer {
private streamItems?: ContentType[]
private streamContextItemOriginalMessage?: ComponentMessage
private streamItemsOriginalMessage?: ComponentMessage
private removeItemObserver: () => void
private loggingEnabled = false
public identifier = nonSecureRandomIdentifier()
private actionObservers: ActionObserver[] = []
public overrideContextItem?: DecryptedItemInterface
private featureStatus: FeatureStatus
private removeFeaturesObserver: () => void
private eventObservers: EventObserver[] = []
private dealloced = false
private window?: Window
private hidden = false
private readonly = false
public lockReadonly = false
public sessionKey?: string
constructor(
public readonly component: SNComponent,
private itemManager: ItemManager,
private syncService: SNSyncService,
private alertService: AlertService,
private preferencesSerivce: SNPreferencesService,
featuresService: SNFeaturesService,
private environment: Environment,
private platform: Platform,
private componentManagerFunctions: ComponentManagerFunctions,
public readonly url?: string,
private contextItemUuid?: UuidString,
actionObserver?: ActionObserver,
) {
this.removeItemObserver = this.itemManager.addObserver(
ContentType.Any,
({ changed, inserted, removed, source, sourceKey }) => {
if (this.dealloced) {
return
}
const items = [...changed, ...inserted, ...removed]
this.handleChangesInItems(items, source, sourceKey)
},
)
if (actionObserver) {
this.actionObservers.push(actionObserver)
}
this.featureStatus = featuresService.getFeatureStatus(component.identifier)
this.removeFeaturesObserver = featuresService.addEventObserver((event) => {
if (this.dealloced) {
return
}
if (event === FeaturesEvent.FeaturesUpdated) {
const featureStatus = featuresService.getFeatureStatus(component.identifier)
if (featureStatus !== this.featureStatus) {
this.featureStatus = featureStatus
this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated)
}
}
})
this.log('Constructor', this)
}
get isDesktop(): boolean {
return this.environment === Environment.Desktop
}
get isMobile(): boolean {
return this.environment === Environment.Mobile
}
public destroy(): void {
this.log('Destroying', this)
this.deinit()
}
private deinit(): void {
this.dealloced = true
;(this.component as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.preferencesSerivce as unknown) = undefined
;(this.componentManagerFunctions as unknown) = undefined
this.eventObservers.length = 0
this.actionObservers.length = 0
this.removeFeaturesObserver()
;(this.removeFeaturesObserver as unknown) = undefined
this.removeItemObserver()
;(this.removeItemObserver as unknown) = undefined
}
public addEventObserver(observer: EventObserver): () => void {
this.eventObservers.push(observer)
const thislessChangeObservers = this.eventObservers
return () => {
removeFromArray(thislessChangeObservers, observer)
}
}
private notifyEventObservers(event: ComponentViewerEvent): void {
for (const observer of this.eventObservers) {
observer(event)
}
}
public addActionObserver(observer: ActionObserver): () => void {
this.actionObservers.push(observer)
const thislessChangeObservers = this.actionObservers
return () => {
removeFromArray(thislessChangeObservers, observer)
}
}
public setReadonly(readonly: boolean): void {
if (this.lockReadonly) {
throw Error('Attempting to set readonly on lockedReadonly component viewer')
}
this.readonly = readonly
}
get componentUuid(): string {
return this.component.uuid
}
public getFeatureStatus(): FeatureStatus {
return this.featureStatus
}
private isOfflineRestricted(): boolean {
return this.component.offlineOnly && !this.isDesktop
}
private isNativeFeature(): boolean {
return !!FindNativeFeature(this.component.identifier)
}
private hasUrlError(): boolean {
if (this.isNativeFeature()) {
return false
}
return this.isDesktop
? !this.component.local_url && !this.component.hasValidHostedUrl()
: !this.component.hasValidHostedUrl()
}
public shouldRender(): boolean {
return this.getError() == undefined
}
public getError(): ComponentViewerError | undefined {
if (this.isOfflineRestricted()) {
return ComponentViewerError.OfflineRestricted
}
if (this.hasUrlError()) {
return ComponentViewerError.MissingUrl
}
return undefined
}
private updateOurComponentRefFromChangedItems(items: DecryptedItemInterface[]): void {
const updatedComponent = items.find((item) => item.uuid === this.component.uuid)
if (updatedComponent && isDecryptedItem(updatedComponent)) {
;(this.component as Writeable<SNComponent>) = updatedComponent as SNComponent
}
}
handleChangesInItems(
items: (DecryptedItemInterface | DeletedItemInterface | EncryptedItemInterface)[],
source: PayloadEmitSource,
sourceKey?: string,
): void {
const nonencryptedItems = items.filter(isNotEncryptedItem)
const nondeletedItems = nonencryptedItems.filter(isDecryptedItem)
this.updateOurComponentRefFromChangedItems(nondeletedItems)
const areWeOriginator = sourceKey && sourceKey === this.component.uuid
if (areWeOriginator) {
return
}
if (this.streamItems) {
const relevantItems = nonencryptedItems.filter((item) => {
return this.streamItems?.includes(item.content_type)
})
if (relevantItems.length > 0) {
this.sendManyItemsThroughBridge(relevantItems)
}
}
if (this.streamContextItemOriginalMessage) {
const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid })
if (matchingItem) {
this.sendContextItemThroughBridge(matchingItem, source)
}
}
}
sendManyItemsThroughBridge(items: (DecryptedItemInterface | DeletedItemInterface)[]): void {
const requiredPermissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: this.streamItems!.sort(),
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
this.sendItemsInReply(items, this.streamItemsOriginalMessage!)
})
}
sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void {
const requiredContextPermissions = [
{
name: ComponentAction.StreamContextItem,
},
] as ComponentPermission[]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredContextPermissions, () => {
this.log(
'Send context item in reply',
'component:',
this.component,
'item: ',
item,
'originalMessage: ',
this.streamContextItemOriginalMessage,
)
const response: MessageReplyData = {
item: this.jsonForItem(item, source),
}
this.replyToMessage(this.streamContextItemOriginalMessage!, response)
})
}
private log(message: string, ...args: unknown[]): void {
if (this.loggingEnabled) {
log('ComponentViewer', message, args)
}
}
private sendItemsInReply(
items: (DecryptedItemInterface | DeletedItemInterface)[],
message: ComponentMessage,
source?: PayloadEmitSource,
): void {
this.log('Send items in reply', this.component, items, message)
const responseData: MessageReplyData = {}
const mapped = items.map((item) => {
return this.jsonForItem(item, source)
})
responseData.items = mapped
this.replyToMessage(message, responseData)
}
private jsonForItem(
item: DecryptedItemInterface | DeletedItemInterface,
source?: PayloadEmitSource,
): OutgoingItemMessagePayload {
const isMetadatUpdate =
source === PayloadEmitSource.RemoteSaved ||
source === PayloadEmitSource.OfflineSyncSaved ||
source === PayloadEmitSource.PreSyncSave
const params: OutgoingItemMessagePayload = {
uuid: item.uuid,
content_type: item.content_type,
created_at: item.created_at,
updated_at: item.serverUpdatedAt,
isMetadataUpdate: isMetadatUpdate,
}
if (isDecryptedItem(item)) {
params.content = this.contentForItem(item)
const globalComponentData = item.getDomainData(ComponentDataDomain) || {}
const thisComponentData = globalComponentData[this.component.getClientDataKey()] || {}
params.clientData = thisComponentData as Record<string, unknown>
} else {
params.deleted = true
}
return this.responseItemsByRemovingPrivateProperties([params])[0]
}
contentForItem(item: DecryptedItemInterface): ItemContent | undefined {
if (isNote(item)) {
const content = item.content
const spellcheck =
item.spellcheck != undefined
? item.spellcheck
: this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true)
return {
...content,
spellcheck,
} as NoteContent
}
return item.content
}
private replyToMessage(originalMessage: ComponentMessage, replyData: MessageReplyData): void {
const reply: MessageReply = {
action: ComponentAction.Reply,
original: originalMessage,
data: replyData,
}
this.sendMessage(reply)
}
/**
* @param essential If the message is non-essential, no alert will be shown
* if we can no longer find the window.
*/
sendMessage(message: ComponentMessage | MessageReply, essential = true): void {
const permissibleActionsWhileHidden = [ComponentAction.ComponentRegistered, ComponentAction.ActivateThemes]
if (this.hidden && !permissibleActionsWhileHidden.includes(message.action)) {
this.log('Component disabled for current item, ignoring messages.', this.component.name)
return
}
if (!this.window && message.action === ComponentAction.Reply) {
this.log('Component has been deallocated in between message send and reply', this.component, message)
return
}
this.log('Send message to component', this.component, 'message: ', message)
let origin = this.url
if (!origin || !this.window) {
if (essential) {
void this.alertService.alert(
`Standard Notes is trying to communicate with ${this.component.name}, ` +
'but an error is occurring. Please restart this extension and try again.',
)
}
return
}
if (!origin.startsWith('http') && !origin.startsWith('file')) {
/* Native extension running in web, prefix current host */
origin = window.location.href + origin
}
/* Mobile messaging requires json */
this.window.postMessage(this.isMobile ? JSON.stringify(message) : message, origin)
}
private responseItemsByRemovingPrivateProperties<T extends OutgoingItemMessagePayload | IncomingComponentItemPayload>(
responseItems: T[],
removeUrls = false,
): T[] {
/* Don't allow component to overwrite these properties. */
let privateContentProperties = ['autoupdateDisabled', 'permissions', 'active']
if (removeUrls) {
privateContentProperties = privateContentProperties.concat(['hosted_url', 'local_url'])
}
return responseItems.map((responseItem) => {
const privateProperties = privateContentProperties.slice()
/** Server extensions are allowed to modify url property */
if (removeUrls) {
privateProperties.push('url')
}
if (!responseItem.content || isString(responseItem.content)) {
return responseItem
}
let content: Partial<ItemContent> = {}
for (const [key, value] of Object.entries(responseItem.content)) {
if (!privateProperties.includes(key)) {
content = {
...content,
[key]: value,
}
}
}
return {
...responseItem,
content: content,
}
})
}
public getWindow(): Window | undefined {
return this.window
}
/** Called by client when the iframe is ready */
public setWindow(window: Window): void {
if (this.window) {
throw Error('Attempting to override component viewer window. Create a new component viewer instead.')
}
this.log('setWindow', 'component: ', this.component, 'window: ', window)
this.window = window
this.sessionKey = UuidGenerator.GenerateUuid()
this.sendMessage({
action: ComponentAction.ComponentRegistered,
sessionKey: this.sessionKey,
componentData: this.component.componentData,
data: {
uuid: this.component.uuid,
environment: environmentToString(this.environment),
platform: platformToString(this.platform),
activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(),
},
})
this.log('setWindow got new sessionKey', this.sessionKey)
this.postActiveThemes()
}
postActiveThemes(): void {
const urls = this.componentManagerFunctions.urlsForActiveThemes()
const data: MessageData = {
themes: urls,
}
const message: ComponentMessage = {
action: ComponentAction.ActivateThemes,
data: data,
}
this.sendMessage(message, false)
}
/* A hidden component will not receive messages. However, when a component is unhidden,
* we need to send it any items it may have registered streaming for. */
public setHidden(hidden: boolean): void {
if (hidden) {
this.hidden = true
} else if (this.hidden) {
this.hidden = false
if (this.streamContextItemOriginalMessage) {
this.handleStreamContextItemMessage(this.streamContextItemOriginalMessage)
}
if (this.streamItems) {
this.handleStreamItemsMessage(this.streamItemsOriginalMessage!)
}
}
}
handleMessage(message: ComponentMessage): void {
this.log('Handle message', message, this)
if (!this.component) {
this.log('Component not defined for message, returning', message)
void this.alertService.alert(
'A component is trying to communicate with Standard Notes, ' +
'but there is an error establishing a bridge. Please restart the app and try again.',
)
return
}
if (this.readonly && ReadwriteActions.includes(message.action)) {
void this.alertService.alert(
`${this.component.name} is trying to save, but it is in a locked state and cannot accept changes.`,
)
return
}
const messageHandlers: Partial<Record<ComponentAction, (message: ComponentMessage) => void>> = {
[ComponentAction.StreamItems]: this.handleStreamItemsMessage.bind(this),
[ComponentAction.StreamContextItem]: this.handleStreamContextItemMessage.bind(this),
[ComponentAction.SetComponentData]: this.handleSetComponentDataMessage.bind(this),
[ComponentAction.DeleteItems]: this.handleDeleteItemsMessage.bind(this),
[ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this),
[ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this),
[ComponentAction.SaveItems]: this.handleSaveItemsMessage.bind(this),
[ComponentAction.SetSize]: this.handleSetSizeEvent.bind(this),
}
const handler = messageHandlers[message.action]
handler?.(message)
for (const observer of this.actionObservers) {
observer(message.action, message.data)
}
}
handleStreamItemsMessage(message: ComponentMessage): void {
const data = message.data as StreamItemsMessageData
const types = data.content_types.filter((type) => AllowedBatchContentTypes.includes(type)).sort()
const requiredPermissions = [
{
name: ComponentAction.StreamItems,
content_types: types,
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
if (!this.streamItems) {
this.streamItems = types
this.streamItemsOriginalMessage = message
}
/* Push immediately now */
const items: DecryptedItemInterface[] = []
for (const contentType of types) {
extendArray(items, this.itemManager.getItems(contentType))
}
this.sendItemsInReply(items, message)
})
}
handleStreamContextItemMessage(message: ComponentMessage): void {
const requiredPermissions: ComponentPermission[] = [
{
name: ComponentAction.StreamContextItem,
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => {
if (!this.streamContextItemOriginalMessage) {
this.streamContextItemOriginalMessage = message
}
const matchingItem = this.overrideContextItem || this.itemManager.findItem(this.contextItemUuid!)
if (matchingItem) {
this.sendContextItemThroughBridge(matchingItem)
}
})
}
/**
* Save items is capable of saving existing items, and also creating new ones
* if they don't exist.
*/
handleSaveItemsMessage(message: ComponentMessage): void {
let responsePayloads = message.data.items as IncomingComponentItemPayload[]
const requiredPermissions = []
/* Pending as in needed to be accounted for in permissions. */
const pendingResponseItems = responsePayloads.slice()
for (const responseItem of responsePayloads.slice()) {
if (responseItem.uuid === this.contextItemUuid) {
requiredPermissions.push({
name: ComponentAction.StreamContextItem,
})
removeFromArray(pendingResponseItems, responseItem)
/* We break because there can only be one context item */
break
}
}
/* Check to see if additional privileges are required */
if (pendingResponseItems.length > 0) {
const requiredContentTypes = uniq(
pendingResponseItems.map((item) => {
return item.content_type
}),
).sort()
requiredPermissions.push({
name: ComponentAction.StreamItems,
content_types: requiredContentTypes,
} as ComponentPermission)
}
this.componentManagerFunctions.runWithPermissions(
this.component.uuid,
requiredPermissions,
async () => {
responsePayloads = this.responseItemsByRemovingPrivateProperties(responsePayloads, true)
/* Filter locked items */
const uuids = Uuids(responsePayloads)
const items = this.itemManager.findItemsIncludingBlanks(uuids)
let lockedCount = 0
let lockedNoteCount = 0
for (const item of items) {
if (!item) {
continue
}
if (item.locked) {
remove(responsePayloads, { uuid: item.uuid })
lockedCount++
if (item.content_type === ContentType.Note) {
lockedNoteCount++
}
}
}
if (lockedNoteCount === 1) {
void this.alertService.alert(
'The note you are attempting to save has editing disabled',
'Note has Editing Disabled',
)
return
} else if (lockedCount > 0) {
const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items'
const auxVerb = lockedCount === 1 ? 'has' : 'have'
void this.alertService.alert(
`${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`,
'Items have Editing Disabled',
)
return
}
const contextualPayloads = responsePayloads.map((responseItem) => {
return CreateComponentRetrievedContextPayload(responseItem)
})
for (const contextualPayload of contextualPayloads) {
const item = this.itemManager.findItem(contextualPayload.uuid)
if (!item) {
const payload = new DecryptedPayload({
...PayloadTimestampDefaults(),
...contextualPayload,
})
const template = CreateDecryptedItemFromPayload(payload)
await this.itemManager.insertItem(template)
} else {
if (contextualPayload.content_type !== item.content_type) {
throw Error('Extension is trying to modify content type of item.')
}
}
}
await this.itemManager.changeItems(
items.filter(isNotUndefined),
(mutator) => {
const contextualPayload = sureSearchArray(contextualPayloads, {
uuid: mutator.getUuid(),
})
mutator.setCustomContent(contextualPayload.content)
const responseItem = sureSearchArray(responsePayloads, {
uuid: mutator.getUuid(),
})
if (responseItem.clientData) {
const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {})
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
mutator.setDomainData(allComponentData, ComponentDataDomain)
}
},
MutationType.UpdateUserTimestamps,
PayloadEmitSource.ComponentRetrieved,
this.component.uuid,
)
this.syncService
.sync({
onPresyncSave: () => {
this.replyToMessage(message, {})
},
})
.catch(() => {
this.replyToMessage(message, {
error: 'save-error',
})
})
},
)
}
handleCreateItemsMessage(message: ComponentMessage): void {
let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[]
const uniqueContentTypes = uniq(
responseItems.map((item) => {
return item.content_type
}),
)
const requiredPermissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: uniqueContentTypes,
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
responseItems = this.responseItemsByRemovingPrivateProperties(responseItems)
const processedItems = []
for (const responseItem of responseItems) {
if (!responseItem.uuid) {
responseItem.uuid = UuidGenerator.GenerateUuid()
}
const contextualPayload = createComponentCreatedContextPayload(responseItem)
const payload = new DecryptedPayload({
...PayloadTimestampDefaults(),
...contextualPayload,
})
const template = CreateDecryptedItemFromPayload(payload)
const item = await this.itemManager.insertItem(template)
await this.itemManager.changeItem(
item,
(mutator) => {
if (responseItem.clientData) {
const allComponentData = Copy(item.getDomainData(ComponentDataDomain) || {})
allComponentData[this.component.getClientDataKey()] = responseItem.clientData
mutator.setDomainData(allComponentData, ComponentDataDomain)
}
},
MutationType.UpdateUserTimestamps,
PayloadEmitSource.ComponentCreated,
this.component.uuid,
)
processedItems.push(item)
}
void this.syncService.sync()
const reply =
message.action === ComponentAction.CreateItem
? { item: this.jsonForItem(processedItems[0]) }
: {
items: processedItems.map((item) => {
return this.jsonForItem(item)
}),
}
this.replyToMessage(message, reply)
})
}
handleDeleteItemsMessage(message: ComponentMessage): void {
const data = message.data as DeleteItemsMessageData
const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type))
const requiredContentTypes = uniq(items.map((item) => item.content_type)).sort() as ContentType[]
const requiredPermissions: ComponentPermission[] = [
{
name: ComponentAction.StreamItems,
content_types: requiredContentTypes,
},
]
this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => {
const itemsData = items
const noun = itemsData.length === 1 ? 'item' : 'items'
let reply = null
const didConfirm = await this.alertService.confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`)
if (didConfirm) {
/* Filter for any components and deactivate before deleting */
for (const itemData of itemsData) {
const item = this.itemManager.findItem(itemData.uuid)
if (!item) {
void this.alertService.alert('The item you are trying to delete cannot be found.')
continue
}
await this.itemManager.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved)
}
void this.syncService.sync()
reply = { deleted: true }
} else {
/* Rejected by user */
reply = { deleted: false }
}
this.replyToMessage(message, reply)
})
}
handleSetComponentDataMessage(message: ComponentMessage): void {
const noPermissionsRequired: ComponentPermission[] = []
this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => {
await this.itemManager.changeComponent(this.component, (mutator) => {
mutator.componentData = message.data.componentData || {}
})
void this.syncService.sync()
})
}
handleSetSizeEvent(message: ComponentMessage): void {
if (this.component.area !== ComponentArea.EditorStack) {
return
}
const parent = this.getIframe()?.parentElement
if (!parent) {
return
}
const data = message.data
const widthString = isString(data.width) ? data.width : `${data.width}px`
const heightString = isString(data.height) ? data.height : `${data.height}px`
if (parent) {
parent.setAttribute('style', `width:${widthString}; height:${heightString};`)
}
}
getIframe(): HTMLIFrameElement | undefined {
return Array.from(document.getElementsByTagName('iframe')).find(
(iframe) => iframe.dataset.componentViewerId === this.identifier,
)
}
}

View File

@@ -0,0 +1,134 @@
import {
ComponentArea,
ComponentAction,
ComponentPermission,
FeatureIdentifier,
LegacyFileSafeIdentifier,
} from '@standardnotes/features'
import { ItemContent, SNComponent, DecryptedTransferPayload } from '@standardnotes/models'
import { UuidString } from '@Lib/Types/UuidString'
import { ContentType } from '@standardnotes/common'
export interface DesktopManagerInterface {
syncComponentsInstallation(components: SNComponent[]): void
registerUpdateObserver(callback: (component: SNComponent) => void): void
getExtServerHost(): string
}
export type IncomingComponentItemPayload = DecryptedTransferPayload & {
clientData: Record<string, unknown>
}
export type OutgoingItemMessagePayload = {
uuid: string
content_type: ContentType
created_at: Date
updated_at: Date
deleted?: boolean
content?: ItemContent
clientData?: Record<string, unknown>
/**
* isMetadataUpdate implies that the extension should make reference of updated
* metadata, but not update content values as they may be stale relative to what the
* extension currently has.
*/
isMetadataUpdate: boolean
}
/**
* Extensions allowed to batch stream AllowedBatchContentTypes
*/
export const AllowedBatchStreaming = Object.freeze([
LegacyFileSafeIdentifier,
FeatureIdentifier.DeprecatedFileSafe,
FeatureIdentifier.DeprecatedBoldEditor,
])
/**
* Content types which are allowed to be managed/streamed in bulk by a component.
*/
export const AllowedBatchContentTypes = Object.freeze([
ContentType.FilesafeCredentials,
ContentType.FilesafeFileMetadata,
ContentType.FilesafeIntegration,
])
export type StreamObserver = {
identifier: string
componentUuid: UuidString
area: ComponentArea
originalMessage: ComponentMessage
/** contentTypes is optional in the case of a context stream observer */
contentTypes?: ContentType[]
}
export type PermissionDialog = {
component: SNComponent
permissions: ComponentPermission[]
permissionsString: string
actionBlock: (approved: boolean) => void
callback: (approved: boolean) => void
}
export enum KeyboardModifier {
Shift = 'Shift',
Ctrl = 'Control',
Meta = 'Meta',
}
export type MessageData = Partial<{
/** Related to the stream-item-context action */
item?: IncomingComponentItemPayload
/** Related to the stream-items action */
content_types?: ContentType[]
items?: IncomingComponentItemPayload[]
/** Related to the request-permission action */
permissions?: ComponentPermission[]
/** Related to the component-registered action */
componentData?: Record<string, unknown>
uuid?: UuidString
environment?: string
platform?: string
activeThemeUrls?: string[]
/** Related to set-size action */
width?: string | number
height?: string | number
type?: string
/** Related to themes action */
themes?: string[]
/** Related to clear-selection action */
content_type?: ContentType
/** Related to key-pressed action */
keyboardModifier?: KeyboardModifier
}>
export type MessageReplyData = {
approved?: boolean
deleted?: boolean
error?: string
item?: OutgoingItemMessagePayload
items?: OutgoingItemMessagePayload[]
themes?: string[]
}
export type StreamItemsMessageData = MessageData & {
content_types: ContentType[]
}
export type DeleteItemsMessageData = MessageData & {
items: OutgoingItemMessagePayload[]
}
export type ComponentMessage = {
action: ComponentAction
sessionKey?: string
componentData?: Record<string, unknown>
data: MessageData
}
export type MessageReply = {
action: ComponentAction
original: ComponentMessage
data: MessageReplyData
}

View File

@@ -0,0 +1,3 @@
export * from './ComponentManager'
export * from './ComponentViewer'
export * from './Types'

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'

View File

@@ -0,0 +1,249 @@
import { ContentType, Uuid } from '@standardnotes/common'
import { EncryptionService } from '@standardnotes/encryption'
import { isNullOrUndefined, removeFromArray } from '@standardnotes/utils'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNApiService } from '@Lib/Services/Api/ApiService'
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
import { UuidString } from '../../Types/UuidString'
import * as Models from '@standardnotes/models'
import * as Responses from '@standardnotes/responses'
import * as Services from '@standardnotes/services'
import { isErrorDecryptingPayload, PayloadTimestampDefaults, SNNote } from '@standardnotes/models'
/** The amount of revisions per item above which should call for an optimization. */
const DefaultItemRevisionsThreshold = 20
/**
* The amount of characters added or removed that
* constitute a keepable entry after optimization.
*/
const LargeEntryDeltaThreshold = 25
/**
* The history manager is responsible for:
* 1. Transient session history, which include keeping track of changes made in the
* current application session. These change logs (unless otherwise configured) are
* ephemeral and do not persist past application restart. Session history entries are
* added via change observers that trigger when an item changes.
* 2. Remote server history. Entries are automatically added by the server and must be
* retrieved per item via an API call.
*/
export class SNHistoryManager extends Services.AbstractService {
private removeChangeObserver: () => void
/**
* When no history exists for an item yet, we first put it in the staging map.
* Then, the next time the item changes and it has no history, we check the staging map.
* If the entry from the staging map differs from the incoming change, we now add the incoming
* change to the history map and remove it from staging. This is a way to detect when the first
* actual change of an item occurs (especially new items), rather than tracking a change
* as an item propagating through the different PayloadSource
* lifecycles (created, local saved, presyncsave, etc)
*/
private historyStaging: Partial<Record<UuidString, Models.HistoryEntry>> = {}
private history: Models.HistoryMap = {}
private itemRevisionThreshold = DefaultItemRevisionsThreshold
constructor(
private itemManager: ItemManager,
private storageService: DiskStorageService,
private apiService: SNApiService,
private protocolService: EncryptionService,
public deviceInterface: Services.DeviceInterface,
protected override internalEventBus: Services.InternalEventBusInterface,
) {
super(internalEventBus)
this.removeChangeObserver = this.itemManager.addObserver(ContentType.Note, ({ changed, inserted }) => {
this.recordNewHistoryForItems(changed.concat(inserted) as SNNote[])
})
}
public override deinit(): void {
;(this.itemManager as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.history as unknown) = undefined
if (this.removeChangeObserver) {
this.removeChangeObserver()
;(this.removeChangeObserver as unknown) = undefined
}
super.deinit()
}
private recordNewHistoryForItems(items: Models.SNNote[]) {
for (const item of items) {
const itemHistory = this.history[item.uuid] || []
const latestEntry = Models.historyMapFunctions.getNewestRevision(itemHistory)
const historyPayload = new Models.DecryptedPayload<Models.NoteContent>(item.payload)
const currentValueEntry = Models.CreateHistoryEntryForPayload(historyPayload, latestEntry)
if (currentValueEntry.isDiscardable()) {
continue
}
/**
* For every change that comes in, first add it to the staging area.
* Then, only on the next subsequent change do we add this previously
* staged entry
*/
const stagedEntry = this.historyStaging[item.uuid]
/** Add prospective to staging, and consider now adding previously staged as new revision */
this.historyStaging[item.uuid] = currentValueEntry
if (!stagedEntry) {
continue
}
if (stagedEntry.isSameAsEntry(currentValueEntry)) {
continue
}
if (latestEntry && stagedEntry.isSameAsEntry(latestEntry)) {
continue
}
itemHistory.unshift(stagedEntry)
this.history[item.uuid] = itemHistory
this.optimizeHistoryForItem(item.uuid)
}
}
sessionHistoryForItem(item: Models.SNNote): Models.HistoryEntry[] {
return this.history[item.uuid] || []
}
getHistoryMapCopy(): Models.HistoryMap {
const copy = Object.assign({}, this.history)
for (const [key, value] of Object.entries(copy)) {
copy[key] = value.slice()
}
return Object.freeze(copy)
}
/**
* Fetches a list of revisions from the server for an item. These revisions do not
* include the item's content. Instead, each revision's content must be fetched
* individually upon selection via `fetchRemoteRevision`.
*/
async remoteHistoryForItem(item: Models.SNNote): Promise<Responses.RevisionListEntry[] | undefined> {
const response = await this.apiService.getItemRevisions(item.uuid)
if (response.error || isNullOrUndefined(response.data)) {
return undefined
}
return (response as Responses.RevisionListResponse).data
}
/**
* Expands on a revision fetched via `remoteHistoryForItem` by getting a revision's
* complete fields (including encrypted content).
*/
async fetchRemoteRevision(
note: Models.SNNote,
entry: Responses.RevisionListEntry,
): Promise<Models.HistoryEntry | undefined> {
const revisionResponse = await this.apiService.getRevision(entry, note.uuid)
if (revisionResponse.error || isNullOrUndefined(revisionResponse.data)) {
return undefined
}
const revision = (revisionResponse as Responses.SingleRevisionResponse).data
const serverPayload = new Models.EncryptedPayload({
...PayloadTimestampDefaults(),
...revision,
updated_at: new Date(revision.updated_at),
created_at: new Date(revision.created_at),
waitingForKey: false,
errorDecrypting: false,
})
/**
* When an item is duplicated, its revisions also carry over to the newly created item.
* However since the new item has a different UUID than the source item, we must decrypt
* these olders revisions (which have not been mutated after copy) with the source item's
* uuid.
*/
const embeddedParams = this.protocolService.getEmbeddedPayloadAuthenticatedData(serverPayload)
const sourceItemUuid = embeddedParams?.u as Uuid | undefined
const payload = serverPayload.copy({
uuid: sourceItemUuid || revision.item_uuid,
})
if (!Models.isRemotePayloadAllowed(payload)) {
console.error('Remote payload is disallowed', payload)
return undefined
}
const encryptedPayload = new Models.EncryptedPayload(payload)
const decryptedPayload = await this.protocolService.decryptSplitSingle<Models.NoteContent>({
usesItemsKeyWithKeyLookup: { items: [encryptedPayload] },
})
if (isErrorDecryptingPayload(decryptedPayload)) {
return undefined
}
return new Models.HistoryEntry(decryptedPayload)
}
async deleteRemoteRevision(note: SNNote, entry: Responses.RevisionListEntry): Promise<Responses.MinimalHttpResponse> {
const response = await this.apiService.deleteRevision(note.uuid, entry)
return response
}
/**
* Clean up if there are too many revisions. Note itemRevisionThreshold
* is the amount of revisions which above, call for an optimization. An
* optimization may not remove entries above this threshold. It will
* determine what it should keep and what it shouldn't. So, it is possible
* to have a threshold of 60 but have 600 entries, if the item history deems
* those worth keeping.
*
* Rules:
* - Keep an entry if it is the oldest entry
* - Keep an entry if it is the latest entry
* - Keep an entry if it is Significant
* - If an entry is Significant and it is a deletion change, keep the entry before this entry.
*/
optimizeHistoryForItem(uuid: string): void {
const entries = this.history[uuid] || []
if (entries.length <= this.itemRevisionThreshold) {
return
}
const isEntrySignificant = (entry: Models.HistoryEntry) => {
return entry.deltaSize() > LargeEntryDeltaThreshold
}
const keepEntries: Models.HistoryEntry[] = []
const processEntry = (entry: Models.HistoryEntry, index: number, keep: boolean) => {
/**
* Entries may be processed retrospectively, meaning it can be
* decided to be deleted, then an upcoming processing can change that.
*/
if (keep) {
keepEntries.unshift(entry)
if (isEntrySignificant(entry) && entry.operationVector() === -1) {
/** This is a large negative change. Hang on to the previous entry. */
const previousEntry = entries[index + 1]
if (previousEntry) {
keepEntries.unshift(previousEntry)
}
}
} else {
/** Don't keep, remove if in keep */
removeFromArray(keepEntries, entry)
}
}
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
const isSignificant = index === 0 || index === entries.length - 1 || isEntrySignificant(entry)
processEntry(entry, index, isSignificant)
}
const filtered = entries.filter((entry) => {
return keepEntries.includes(entry)
})
this.history[uuid] = filtered
}
}

View File

@@ -0,0 +1 @@
export * from './HistoryManager'

View File

@@ -0,0 +1,742 @@
import { ContentType } from '@standardnotes/common'
import { InternalEventBusInterface } from '@standardnotes/services'
import { ItemManager } from './ItemManager'
import { PayloadManager } from '../Payloads/PayloadManager'
import { UuidGenerator } from '@standardnotes/utils'
import * as Models from '@standardnotes/models'
import {
DecryptedPayload,
DeletedPayload,
EncryptedPayload,
FillItemContent,
PayloadTimestampDefaults,
NoteContent,
} from '@standardnotes/models'
const setupRandomUuid = () => {
UuidGenerator.SetGenerator(() => String(Math.random()))
}
const VIEW_NOT_PINNED = '!["Not Pinned", "pinned", "=", false]'
const VIEW_LAST_DAY = '!["Last Day", "updated_at", ">", "1.days.ago"]'
const VIEW_LONG = '!["Long", "text.length", ">", 500]'
const NotPinnedPredicate = Models.predicateFromJson<Models.SNTag>({
keypath: 'pinned',
operator: '=',
value: false,
})
const LastDayPredicate = Models.predicateFromJson<Models.SNTag>({
keypath: 'updated_at',
operator: '>',
value: '1.days.ago',
})
const LongTextPredicate = Models.predicateFromJson<Models.SNTag>({
keypath: 'text.length' as never,
operator: '>',
value: 500,
})
describe('itemManager', () => {
let payloadManager: PayloadManager
let itemManager: ItemManager
let items: Models.DecryptedItemInterface[]
let internalEventBus: InternalEventBusInterface
const createService = () => {
return new ItemManager(payloadManager, { supportsFileNavigation: false }, internalEventBus)
}
beforeEach(() => {
setupRandomUuid()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
payloadManager = new PayloadManager(internalEventBus)
items = [] as jest.Mocked<Models.DecryptedItemInterface[]>
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue(items)
itemManager.createItem = jest.fn()
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<Models.DecryptedItemInterface>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
})
const createTag = (title: string) => {
return new Models.SNTag(
new Models.DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.Tag,
content: Models.FillItemContent<Models.TagContent>({
title: title,
}),
...PayloadTimestampDefaults(),
}),
)
}
const createNote = (title: string) => {
return new Models.SNNote(
new Models.DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
content: Models.FillItemContent<Models.NoteContent>({
title: title,
}),
...PayloadTimestampDefaults(),
}),
)
}
const createFile = (name: string) => {
return new Models.FileItem(
new Models.DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.File,
content: Models.FillItemContent<Models.FileContent>({
name: name,
}),
...PayloadTimestampDefaults(),
}),
)
}
describe('item emit', () => {
it('deleted payloads should map to removed items', async () => {
itemManager = createService()
const payload = new DeletedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
content: undefined,
deleted: true,
dirty: true,
...PayloadTimestampDefaults(),
})
const mockFn = jest.fn()
itemManager['notifyObservers'] = mockFn
await payloadManager.emitPayload(payload, Models.PayloadEmitSource.LocalInserted)
expect(mockFn.mock.calls[0][2]).toHaveLength(1)
})
it('decrypted items who become encrypted should be removed from ui', async () => {
itemManager = createService()
const decrypted = new DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
content: FillItemContent<NoteContent>({
title: 'foo',
}),
...PayloadTimestampDefaults(),
})
await payloadManager.emitPayload(decrypted, Models.PayloadEmitSource.LocalInserted)
const encrypted = new EncryptedPayload({
...decrypted,
content: '004:...',
enc_item_key: '004:...',
items_key_id: '123',
waitingForKey: true,
errorDecrypting: true,
})
const mockFn = jest.fn()
itemManager['notifyObservers'] = mockFn
await payloadManager.emitPayload(encrypted, Models.PayloadEmitSource.LocalInserted)
expect(mockFn.mock.calls[0][2]).toHaveLength(1)
})
})
describe('note display criteria', () => {
it('viewing notes with tag', async () => {
itemManager = createService()
const tag = createTag('parent')
const note = createNote('note')
await itemManager.insertItems([tag, note])
await itemManager.addTagToNote(note, tag, false)
itemManager.setPrimaryItemDisplayOptions({
tags: [tag],
sortBy: 'title',
sortDirection: 'asc',
})
const notes = itemManager.getDisplayableNotes()
expect(notes).toHaveLength(1)
})
})
describe('tag relationships', () => {
it('updates parentId of child tag', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
const changedChild = itemManager.findItem(child.uuid) as Models.SNTag
expect(changedChild.parentId).toBe(parent.uuid)
})
it('forbids a tag to be its own parent', async () => {
itemManager = createService()
const tag = createTag('tag')
await itemManager.insertItems([tag])
expect(() => itemManager.setTagParent(tag, tag)).toThrow()
expect(itemManager.getTagParent(tag)).toBeUndefined()
})
it('forbids a tag to be its own ancestor', async () => {
itemManager = createService()
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([child, parent, grandParent])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
expect(() => itemManager.setTagParent(child, grandParent)).toThrow()
expect(itemManager.getTagParent(grandParent)).toBeUndefined()
})
it('getTagParent', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid)
})
it('findTagByTitleAndParent', async () => {
itemManager = createService()
const parent = createTag('name1')
const child = createTag('childName')
const duplicateNameChild = createTag('name1')
await itemManager.insertItems([parent, child, duplicateNameChild])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(parent, duplicateNameChild)
const a = itemManager.findTagByTitleAndParent('name1', undefined)
const b = itemManager.findTagByTitleAndParent('name1', parent)
const c = itemManager.findTagByTitleAndParent('name1', child)
expect(a?.uuid).toEqual(parent.uuid)
expect(b?.uuid).toEqual(duplicateNameChild.uuid)
expect(c?.uuid).toEqual(undefined)
})
it('findOrCreateTagByTitle', async () => {
setupRandomUuid()
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
const childA = await itemManager.findOrCreateTagByTitle('child')
const childB = await itemManager.findOrCreateTagByTitle('child', parent)
const childC = await itemManager.findOrCreateTagByTitle('child-bis', parent)
const childD = await itemManager.findOrCreateTagByTitle('child-bis', parent)
expect(childA.uuid).not.toEqual(child.uuid)
expect(childB.uuid).toEqual(child.uuid)
expect(childD.uuid).toEqual(childC.uuid)
expect(itemManager.getTagParent(childA)?.uuid).toBe(undefined)
expect(itemManager.getTagParent(childB)?.uuid).toBe(parent.uuid)
expect(itemManager.getTagParent(childC)?.uuid).toBe(parent.uuid)
expect(itemManager.getTagParent(childD)?.uuid).toBe(parent.uuid)
})
it('findOrCreateTagParentChain', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
const a = await itemManager.findOrCreateTagParentChain(['parent'])
const b = await itemManager.findOrCreateTagParentChain(['parent', 'child'])
const c = await itemManager.findOrCreateTagParentChain(['parent', 'child2'])
const d = await itemManager.findOrCreateTagParentChain(['parent2', 'child1'])
expect(a?.uuid).toEqual(parent.uuid)
expect(b?.uuid).toEqual(child.uuid)
expect(c?.uuid).not.toEqual(parent.uuid)
expect(c?.uuid).not.toEqual(child.uuid)
expect(c?.parentId).toEqual(parent.uuid)
expect(d?.uuid).not.toEqual(parent.uuid)
expect(d?.uuid).not.toEqual(child.uuid)
expect(d?.parentId).not.toEqual(parent.uuid)
})
it('isAncestor', async () => {
itemManager = createService()
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
const another = createTag('another')
await itemManager.insertItems([child, parent, grandParent, another])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
expect(itemManager.isTagAncestor(grandParent, parent)).toEqual(true)
expect(itemManager.isTagAncestor(grandParent, child)).toEqual(true)
expect(itemManager.isTagAncestor(parent, child)).toEqual(true)
expect(itemManager.isTagAncestor(parent, grandParent)).toBeFalsy()
expect(itemManager.isTagAncestor(child, grandParent)).toBeFalsy()
expect(itemManager.isTagAncestor(grandParent, grandParent)).toBeFalsy()
expect(itemManager.isTagAncestor(another, grandParent)).toBeFalsy()
expect(itemManager.isTagAncestor(child, another)).toBeFalsy()
expect(itemManager.isTagAncestor(grandParent, another)).toBeFalsy()
})
it('unsetTagRelationship', async () => {
itemManager = createService()
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([parent, child])
await itemManager.setTagParent(parent, child)
expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid)
await itemManager.unsetTagParent(child)
expect(itemManager.getTagParent(child)).toBeUndefined()
})
it('getTagParentChain', async () => {
itemManager = createService()
const greatGrandParent = createTag('greatGrandParent')
const grandParent = createTag('grandParent')
const parent = createTag('parent')
const child = createTag('child')
await itemManager.insertItems([greatGrandParent, grandParent, parent, child])
await itemManager.setTagParent(parent, child)
await itemManager.setTagParent(grandParent, parent)
await itemManager.setTagParent(greatGrandParent, grandParent)
const uuidChain = itemManager.getTagParentChain(child).map((tag) => tag.uuid)
expect(uuidChain).toHaveLength(3)
expect(uuidChain).toEqual([greatGrandParent.uuid, grandParent.uuid, parent.uuid])
})
it('viewing notes for parent tag should not display notes of children', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
await itemManager.insertItems([parentTag, childTag])
await itemManager.setTagParent(parentTag, childTag)
const parentNote = createNote('parentNote')
const childNote = createNote('childNote')
await itemManager.insertItems([parentNote, childNote])
await itemManager.addTagToNote(parentNote, parentTag, false)
await itemManager.addTagToNote(childNote, childTag, false)
itemManager.setPrimaryItemDisplayOptions({
tags: [parentTag],
sortBy: 'title',
sortDirection: 'asc',
})
const notes = itemManager.getDisplayableNotes()
expect(notes).toHaveLength(1)
})
it('adding a note to a tag hierarchy should add the note to its parent too', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const note = createNote('note')
await itemManager.insertItems([parentTag, childTag, note])
await itemManager.setTagParent(parentTag, childTag)
await itemManager.addTagToNote(note, childTag, true)
const tags = itemManager.getSortedTagsForNote(note)
expect(tags).toHaveLength(2)
expect(tags[0].uuid).toEqual(childTag.uuid)
expect(tags[1].uuid).toEqual(parentTag.uuid)
})
it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
const note = createNote('note')
await itemManager.insertItems([parentTag, childTag, note])
await itemManager.setTagParent(parentTag, childTag)
await itemManager.addTagToNote(note, childTag, false)
const tags = itemManager.getSortedTagsForNote(note)
expect(tags).toHaveLength(1)
expect(tags[0].uuid).toEqual(childTag.uuid)
})
})
describe('template items', () => {
it('create template item', async () => {
itemManager = createService()
setupRandomUuid()
const item = await itemManager.createTemplateItem(ContentType.Note, {
title: 'hello',
references: [],
})
expect(!!item).toEqual(true)
/* Template items should never be added to the record */
expect(itemManager.items).toHaveLength(0)
expect(itemManager.getDisplayableNotes()).toHaveLength(0)
})
it('isTemplateItem return the correct value', async () => {
itemManager = createService()
setupRandomUuid()
const item = await itemManager.createTemplateItem(ContentType.Note, {
title: 'hello',
references: [],
})
expect(itemManager.isTemplateItem(item)).toEqual(true)
await itemManager.insertItem(item)
expect(itemManager.isTemplateItem(item)).toEqual(false)
})
it('isTemplateItem return the correct value for system smart views', () => {
itemManager = createService()
setupRandomUuid()
const [systemTag1, ...restOfSystemViews] = itemManager
.getSmartViews()
.filter((view) => Object.values(Models.SystemViewId).includes(view.uuid as Models.SystemViewId))
const isSystemTemplate = itemManager.isTemplateItem(systemTag1)
expect(isSystemTemplate).toEqual(false)
const areTemplates = restOfSystemViews.map((tag) => itemManager.isTemplateItem(tag)).every((value) => !!value)
expect(areTemplates).toEqual(false)
})
})
describe('tags', () => {
it('lets me create a regular tag with a clear API', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createTag('this is my new tag')
expect(tag).toBeTruthy()
expect(itemManager.isTemplateItem(tag)).toEqual(false)
})
it('should search tags correctly', async () => {
itemManager = createService()
setupRandomUuid()
const foo = await itemManager.createTag('foo[')
const foobar = await itemManager.createTag('foo[bar]')
const bar = await itemManager.createTag('bar[')
const barfoo = await itemManager.createTag('bar[foo]')
const fooDelimiter = await itemManager.createTag('bar.foo')
const barFooDelimiter = await itemManager.createTag('baz.bar.foo')
const fooAttached = await itemManager.createTag('Foo')
const note = createNote('note')
await itemManager.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note])
await itemManager.addTagToNote(note, fooAttached, false)
const fooResults = itemManager.searchTags('foo')
expect(fooResults).toContainEqual(foo)
expect(fooResults).toContainEqual(foobar)
expect(fooResults).toContainEqual(barfoo)
expect(fooResults).toContainEqual(fooDelimiter)
expect(fooResults).toContainEqual(barFooDelimiter)
expect(fooResults).not.toContainEqual(bar)
expect(fooResults).not.toContainEqual(fooAttached)
})
})
describe('tags notes index', () => {
it('counts countable notes', async () => {
itemManager = createService()
const parentTag = createTag('parent')
const childTag = createTag('child')
await itemManager.insertItems([parentTag, childTag])
await itemManager.setTagParent(parentTag, childTag)
const parentNote = createNote('parentNote')
const childNote = createNote('childNote')
await itemManager.insertItems([parentNote, childNote])
await itemManager.addTagToNote(parentNote, parentTag, false)
await itemManager.addTagToNote(childNote, childTag, false)
expect(itemManager.countableNotesForTag(parentTag)).toBe(1)
expect(itemManager.countableNotesForTag(childTag)).toBe(1)
expect(itemManager.allCountableNotesCount()).toBe(2)
})
it('archiving a note should update count index', async () => {
itemManager = createService()
const tag1 = createTag('tag 1')
await itemManager.insertItems([tag1])
const note1 = createNote('note 1')
const note2 = createNote('note 2')
await itemManager.insertItems([note1, note2])
await itemManager.addTagToNote(note1, tag1, false)
await itemManager.addTagToNote(note2, tag1, false)
expect(itemManager.countableNotesForTag(tag1)).toBe(2)
expect(itemManager.allCountableNotesCount()).toBe(2)
await itemManager.changeItem<Models.NoteMutator>(note1, (m) => {
m.archived = true
})
expect(itemManager.allCountableNotesCount()).toBe(1)
expect(itemManager.countableNotesForTag(tag1)).toBe(1)
await itemManager.changeItem<Models.NoteMutator>(note1, (m) => {
m.archived = false
})
expect(itemManager.allCountableNotesCount()).toBe(2)
expect(itemManager.countableNotesForTag(tag1)).toBe(2)
})
})
describe('smart views', () => {
it('lets me create a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const [view1, view2, view3] = await Promise.all([
itemManager.createSmartView('Not Pinned', NotPinnedPredicate),
itemManager.createSmartView('Last Day', LastDayPredicate),
itemManager.createSmartView('Long', LongTextPredicate),
])
expect(view1).toBeTruthy()
expect(view2).toBeTruthy()
expect(view3).toBeTruthy()
expect(view1.content_type).toEqual(ContentType.SmartView)
expect(view2.content_type).toEqual(ContentType.SmartView)
expect(view3.content_type).toEqual(ContentType.SmartView)
})
it('lets me use a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const view = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
const notes = itemManager.notesMatchingSmartView(view)
expect(notes).toEqual([])
})
it('lets me test if a title is a smart view', () => {
itemManager = createService()
setupRandomUuid()
expect(itemManager.isSmartViewTitle(VIEW_NOT_PINNED)).toEqual(true)
expect(itemManager.isSmartViewTitle(VIEW_LAST_DAY)).toEqual(true)
expect(itemManager.isSmartViewTitle(VIEW_LONG)).toEqual(true)
expect(itemManager.isSmartViewTitle('Helloworld')).toEqual(false)
expect(itemManager.isSmartViewTitle('@^![ some title')).toEqual(false)
})
it('lets me create a smart view from the DSL', async () => {
itemManager = createService()
setupRandomUuid()
const [tag1, tag2, tag3] = await Promise.all([
itemManager.createSmartViewFromDSL(VIEW_NOT_PINNED),
itemManager.createSmartViewFromDSL(VIEW_LAST_DAY),
itemManager.createSmartViewFromDSL(VIEW_LONG),
])
expect(tag1).toBeTruthy()
expect(tag2).toBeTruthy()
expect(tag3).toBeTruthy()
expect(tag1.content_type).toEqual(ContentType.SmartView)
expect(tag2.content_type).toEqual(ContentType.SmartView)
expect(tag3.content_type).toEqual(ContentType.SmartView)
})
it('will create smart view or tags from the generic method', async () => {
itemManager = createService()
setupRandomUuid()
const someTag = await itemManager.createTagOrSmartView('some-tag')
const someView = await itemManager.createTagOrSmartView(VIEW_LONG)
expect(someTag.content_type).toEqual(ContentType.Tag)
expect(someView.content_type).toEqual(ContentType.SmartView)
})
})
it('lets me rename a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
await itemManager.changeItem<Models.TagMutator>(tag, (m) => {
m.title = 'New Title'
})
const view = itemManager.findItem(tag.uuid) as Models.SmartView
const views = itemManager.getSmartViews()
expect(view.title).toEqual('New Title')
expect(views.some((tag: Models.SmartView) => tag.title === 'New Title')).toEqual(true)
})
it('lets me find a smart view', async () => {
itemManager = createService()
setupRandomUuid()
const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate)
const view = itemManager.findItem(tag.uuid) as Models.SmartView
expect(view).toBeDefined()
})
it('untagged notes smart view', async () => {
itemManager = createService()
setupRandomUuid()
const view = itemManager.untaggedNotesSmartView
const tag = createTag('tag')
const untaggedNote = createNote('note')
const taggedNote = createNote('taggedNote')
await itemManager.insertItems([tag, untaggedNote, taggedNote])
expect(itemManager.notesMatchingSmartView(view)).toHaveLength(2)
await itemManager.addTagToNote(taggedNote, tag, false)
expect(itemManager.notesMatchingSmartView(view)).toHaveLength(1)
expect(view).toBeDefined()
})
describe('files', () => {
it('associates with note', async () => {
itemManager = createService()
const note = createNote('invoices')
const file = createFile('invoice_1.pdf')
await itemManager.insertItems([note, file])
const resultingFile = await itemManager.associateFileWithNote(file, note)
const references = resultingFile.references
expect(references).toHaveLength(1)
expect(references[0].uuid).toEqual(note.uuid)
})
it('disassociates with note', async () => {
itemManager = createService()
const note = createNote('invoices')
const file = createFile('invoice_1.pdf')
await itemManager.insertItems([note, file])
const associatedFile = await itemManager.associateFileWithNote(file, note)
const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note)
const references = disassociatedFile.references
expect(references).toHaveLength(0)
})
it('should get files associated with note', async () => {
itemManager = createService()
const note = createNote('invoices')
const file = createFile('invoice_1.pdf')
const secondFile = createFile('unrelated-file.xlsx')
await itemManager.insertItems([note, file, secondFile])
await itemManager.associateFileWithNote(file, note)
const filesAssociatedWithNote = itemManager.getFilesForNote(note)
expect(filesAssociatedWithNote).toHaveLength(1)
expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid)
})
it('should correctly rename file to filename that has extension', async () => {
itemManager = createService()
const file = createFile('initialName.ext')
await itemManager.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt')
expect(renamedFile.name).toBe('anotherName.anotherExt')
})
it('should correctly rename extensionless file to filename that has extension', async () => {
itemManager = createService()
const file = createFile('initialName')
await itemManager.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt')
expect(renamedFile.name).toBe('anotherName.anotherExt')
})
it('should correctly rename file to filename that does not have extension', async () => {
itemManager = createService()
const file = createFile('initialName.ext')
await itemManager.insertItems([file])
const renamedFile = await itemManager.renameFile(file, 'anotherName')
expect(renamedFile.name).toBe('anotherName')
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import { SNItemsKey } from '@standardnotes/encryption'
import { ContentType } from '@standardnotes/common'
import {
SNNote,
FileItem,
SNTag,
SmartView,
TagNoteCountChangeObserver,
DecryptedPayloadInterface,
EncryptedItemInterface,
DecryptedTransferPayload,
PredicateInterface,
DecryptedItemInterface,
SNComponent,
SNTheme,
DisplayOptions,
} from '@standardnotes/models'
import { UuidString } from '@Lib/Types'
export interface ItemsClientInterface {
get invalidItems(): EncryptedItemInterface[]
associateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
getFilesForNote(note: SNNote): FileItem[]
renameFile(file: FileItem, name: string): Promise<FileItem>
addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]>
/** Creates an unmanaged, un-inserted item from a payload. */
createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface
createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface
get trashedItems(): SNNote[]
setPrimaryItemDisplayOptions(options: DisplayOptions): void
getDisplayableNotes(): SNNote[]
getDisplayableTags(): SNTag[]
getDisplayableItemsKeys(): SNItemsKey[]
getDisplayableFiles(): FileItem[]
getDisplayableNotesAndFiles(): (SNNote | FileItem)[]
getDisplayableComponents(): (SNComponent | SNTheme)[]
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
notesMatchingSmartView(view: SmartView): SNNote[]
addNoteCountChangeObserver(observer: TagNoteCountChangeObserver): () => void
allCountableNotesCount(): number
countableNotesForTag(tag: SNTag | SmartView): number
findTagByTitle(title: string): SNTag | undefined
getTagPrefixTitle(tag: SNTag): string | undefined
getTagLongTitle(tag: SNTag): string
hasTagsNeedingFoldersMigration(): boolean
referencesForItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[]
itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[]
/**
* Finds tags with title or component starting with a search query and (optionally) not associated with a note
* @param searchQuery - The query string to match
* @param note - The note whose tags should be omitted from results
* @returns Array containing tags matching search query and not associated with note
*/
searchTags(searchQuery: string, note?: SNNote): SNTag[]
isValidTagParent(parentTagToLookUpUuidFor: SNTag, childToLookUpUuidFor: SNTag): boolean
/**
* Returns the parent for a tag
*/
getTagParent(itemToLookupUuidFor: SNTag): SNTag | undefined
/**
* Returns the hierarchy of parents for a tag
* @returns Array containing all parent tags
*/
getTagParentChain(itemToLookupUuidFor: SNTag): SNTag[]
/**
* Returns all descendants for a tag
* @returns Array containing all descendant tags
*/
getTagChildren(itemToLookupUuidFor: SNTag): SNTag[]
/**
* Get tags for a note sorted in natural order
* @param note - The note whose tags will be returned
* @returns Array containing tags associated with a note
*/
getSortedTagsForNote(note: SNNote): SNTag[]
isSmartViewTitle(title: string): boolean
getSmartViews(): SmartView[]
getNoteCount(): number
/**
* Finds an item by UUID.
*/
findItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: UuidString): T | undefined
/**
* Finds an item by predicate.
*/
findItems<T extends DecryptedItemInterface>(uuids: UuidString[]): T[]
findSureItem<T extends DecryptedItemInterface = DecryptedItemInterface>(uuid: UuidString): T
/**
* Finds an item by predicate.
*/
itemsMatchingPredicate<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
): T[]
/**
* @param item item to be checked
* @returns Whether the item is a template (unmanaged)
*/
isTemplateItem(item: DecryptedItemInterface): boolean
}

View File

@@ -0,0 +1,8 @@
import * as Models from '@standardnotes/models'
import { UuidString } from '../../Types/UuidString'
export type TransactionalMutation = {
itemUuid: UuidString
mutate: (mutator: Models.ItemMutator) => void
mutationType?: Models.MutationType
}

View File

@@ -0,0 +1,3 @@
export * from './ItemsClientInterface'
export * from './ItemManager'
export * from './TransactionalMutation'

View File

@@ -0,0 +1,65 @@
import { ContentType } from '@standardnotes/common'
import { ItemsKeyInterface } from '@standardnotes/models'
import { dateSorted } from '@standardnotes/utils'
import { SNRootKeyParams, DecryptItemsKeyByPromptingUser, EncryptionProvider } from '@standardnotes/encryption'
import { DecryptionQueueItem, KeyRecoveryOperationResult } from './Types'
import { serverKeyParamsAreSafe } from './Utils'
import { ChallengeServiceInterface } from '@standardnotes/services'
import { ItemManager } from '../Items'
export class KeyRecoveryOperation {
constructor(
private queueItem: DecryptionQueueItem,
private itemManager: ItemManager,
private protocolService: EncryptionProvider,
private challengeService: ChallengeServiceInterface,
private clientParams: SNRootKeyParams | undefined,
private serverParams: SNRootKeyParams | undefined,
) {}
public async run(): Promise<KeyRecoveryOperationResult> {
let replaceLocalRootKeyWithResult = false
const queueItemKeyParamsAreBetterOrEqualToClients =
this.serverParams &&
this.clientParams &&
!this.clientParams.compare(this.serverParams) &&
this.queueItem.keyParams.compare(this.serverParams) &&
serverKeyParamsAreSafe(this.serverParams, this.clientParams)
if (queueItemKeyParamsAreBetterOrEqualToClients) {
const latestDecryptedItemsKey = dateSorted(
this.itemManager.getItems<ItemsKeyInterface>(ContentType.ItemsKey),
'created_at',
false,
)[0]
if (!latestDecryptedItemsKey) {
replaceLocalRootKeyWithResult = true
} else {
replaceLocalRootKeyWithResult = this.queueItem.encryptedKey.created_at > latestDecryptedItemsKey.created_at
}
}
const decryptionResult = await DecryptItemsKeyByPromptingUser(
this.queueItem.encryptedKey,
this.protocolService,
this.challengeService,
this.queueItem.keyParams,
)
if (decryptionResult === 'aborted') {
return { aborted: true }
}
if (decryptionResult === 'failed') {
return { aborted: false }
}
return {
rootKey: decryptionResult.rootKey,
replaceLocalRootKeyWithResult,
decryptedItemsKey: decryptionResult.decryptedKey,
}
}
}

View File

@@ -0,0 +1,535 @@
import { KeyRecoveryOperation } from './KeyRecoveryOperation'
import {
SNRootKeyParams,
EncryptionService,
SNRootKey,
KeyParamsFromApiResponse,
KeyRecoveryStrings,
} from '@standardnotes/encryption'
import { UserService } from '../User/UserService'
import {
isErrorDecryptingPayload,
EncryptedPayloadInterface,
EncryptedPayload,
isDecryptedPayload,
DecryptedPayloadInterface,
PayloadEmitSource,
EncryptedItemInterface,
getIncrementedDirtyIndex,
} from '@standardnotes/models'
import { SNSyncService } from '../Sync/SyncService'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { PayloadManager } from '../Payloads/PayloadManager'
import { Challenge, ChallengeService } from '../Challenge'
import { SNApiService } from '@Lib/Services/Api/ApiService'
import { ContentType, Uuid } from '@standardnotes/common'
import { ItemManager } from '../Items/ItemManager'
import { removeFromArray, Uuids } from '@standardnotes/utils'
import { ClientDisplayableError, KeyParamsResponse } from '@standardnotes/responses'
import {
AlertService,
AbstractService,
InternalEventBusInterface,
StorageValueModes,
ApplicationStage,
StorageKey,
DiagnosticInfo,
ChallengeValidation,
ChallengeReason,
ChallengePrompt,
} from '@standardnotes/services'
import {
UndecryptableItemsStorage,
DecryptionQueueItem,
KeyRecoveryEvent,
isSuccessResult,
KeyRecoveryOperationResult,
} from './Types'
import { serverKeyParamsAreSafe } from './Utils'
/**
* The key recovery service listens to items key changes to detect any that cannot be decrypted.
* If it detects an items key that is not properly decrypted, it will present a key recovery
* wizard (using existing UI like Challenges and AlertService) that will attempt to recover
* the root key for those keys.
*
* When we encounter an items key we cannot decrypt, this is a sign that the user's password may
* have recently changed (even though their session is still valid). If the user has been
* previously signed in, we take this opportunity to reach out to the server to get the
* user's current key_params. We ensure these key params' version is equal to or greater than our own.
* - If this key's key params are equal to the retrieved parameters,
and this keys created date is greater than any existing valid items key,
or if we do not have any items keys:
1. Use the decryption of this key as a source of validation
2. If valid, replace our local root key with this new root key and emit the decrypted items key
* - Else, if the key params are not equal,
or its created date is less than an existing valid items key
1. Attempt to decrypt this key using its attached key paramas
2. If valid, emit decrypted items key. DO NOT replace local root key.
* - If by the end we did not find an items key with matching key params to the retrieved
key params, AND the retrieved key params are newer than what we have locally, we must
issue a sign in request to the server.
* If the user is not signed in and we detect an undecryptable items key, we present a detached
* recovery wizard that doesn't affect our local root key.
*
* When an items key is emitted, protocol service will automatically try to decrypt any
* related items that are in an errored state.
*
* In the item observer, `ignored` items represent items who have encrypted overwrite
* protection enabled (only items keys). This means that if the incoming payload is errored,
* but our current copy is not, we will ignore the incoming value until we can properly
* decrypt it.
*/
export class SNKeyRecoveryService extends AbstractService<KeyRecoveryEvent, DecryptedPayloadInterface[]> {
private removeItemObserver: () => void
private decryptionQueue: DecryptionQueueItem[] = []
private isProcessingQueue = false
constructor(
private itemManager: ItemManager,
private payloadManager: PayloadManager,
private apiService: SNApiService,
private protocolService: EncryptionService,
private challengeService: ChallengeService,
private alertService: AlertService,
private storageService: DiskStorageService,
private syncService: SNSyncService,
private userService: UserService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.removeItemObserver = this.payloadManager.addObserver(
[ContentType.ItemsKey],
({ changed, inserted, ignored, source }) => {
if (source === PayloadEmitSource.LocalChanged) {
return
}
const changedOrInserted = changed.concat(inserted).filter(isErrorDecryptingPayload)
if (changedOrInserted.length > 0) {
void this.handleUndecryptableItemsKeys(changedOrInserted)
}
if (ignored.length > 0) {
void this.handleIgnoredItemsKeys(ignored)
}
},
)
}
public override deinit(): void {
;(this.itemManager as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.protocolService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.userService as unknown) = undefined
this.removeItemObserver()
;(this.removeItemObserver as unknown) = undefined
super.deinit()
}
// eslint-disable-next-line @typescript-eslint/require-await
override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
void super.handleApplicationStage(stage)
if (stage === ApplicationStage.LoadedDatabase_12) {
void this.processPersistedUndecryptables()
}
}
/**
* Ignored items keys are items keys which arrived from a remote source, which we were
* not able to decrypt, and for which we already had an existing items key that was
* properly decrypted. Since items keys key contents are immutable, if we already have a
* successfully decrypted version, yet we can't decrypt the new version, we should
* temporarily ignore the new version until we can properly decrypt it (through the recovery flow),
* and not overwrite the local copy.
*
* Ignored items are persisted to disk in isolated storage so that they may be decrypted
* whenever. When they are finally decryptable, we will emit them and update our database
* with the new decrypted value.
*
* When the app first launches, we will query the isolated storage to see if there are any
* keys we need to decrypt.
*/
private async handleIgnoredItemsKeys(keys: EncryptedPayloadInterface[], persistIncoming = true) {
/**
* Persist the keys locally in isolated storage, so that if we don't properly decrypt
* them in this app session, the user has a chance to later. If there already exists
* the same items key in this storage, replace it with this latest incoming value.
*/
if (persistIncoming) {
this.saveToUndecryptables(keys)
}
this.addKeysToQueue(keys)
await this.beginKeyRecovery()
}
private async handleUndecryptableItemsKeys(keys: EncryptedPayloadInterface[]) {
this.addKeysToQueue(keys)
await this.beginKeyRecovery()
}
public presentKeyRecoveryWizard(): void {
const invalidKeys = this.itemManager.invalidItems
.filter((i) => i.content_type === ContentType.ItemsKey)
.map((i) => i.payload)
void this.handleIgnoredItemsKeys(invalidKeys, false)
}
public canAttemptDecryptionOfItem(item: EncryptedItemInterface): ClientDisplayableError | true {
const keyId = item.payload.items_key_id
if (!keyId) {
return new ClientDisplayableError('This item cannot be recovered.')
}
const key = this.payloadManager.findOne(keyId)
if (!key) {
return new ClientDisplayableError(
`Unable to find key ${keyId} for this item. You may try signing out and back in; if that doesn't help, check your backup files for a key with this ID and import it.`,
)
}
return true
}
public async processPersistedUndecryptables() {
const record = this.getUndecryptables()
const rawPayloads = Object.values(record)
if (rawPayloads.length === 0) {
return
}
const keys = rawPayloads.map((raw) => new EncryptedPayload(raw))
return this.handleIgnoredItemsKeys(keys, false)
}
private getUndecryptables(): UndecryptableItemsStorage {
return this.storageService.getValue<UndecryptableItemsStorage>(
StorageKey.KeyRecoveryUndecryptableItems,
StorageValueModes.Default,
{},
)
}
private persistUndecryptables(record: UndecryptableItemsStorage) {
this.storageService.setValue(StorageKey.KeyRecoveryUndecryptableItems, record)
}
private saveToUndecryptables(keys: EncryptedPayloadInterface[]) {
const record = this.getUndecryptables()
for (const key of keys) {
record[key.uuid] = key.ejected()
}
this.persistUndecryptables(record)
}
private removeFromUndecryptables(keyIds: Uuid[]) {
const record = this.getUndecryptables()
for (const id of keyIds) {
delete record[id]
}
this.persistUndecryptables(record)
}
private getClientKeyParams() {
return this.protocolService.getAccountKeyParams()
}
private async performServerSignIn(): Promise<SNRootKey | undefined> {
const accountPasswordChallenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, undefined, undefined, true)],
ChallengeReason.Custom,
true,
KeyRecoveryStrings.KeyRecoveryLoginFlowReason,
)
const challengeResponse = await this.challengeService.promptForChallengeResponse(accountPasswordChallenge)
if (!challengeResponse) {
return undefined
}
this.challengeService.completeChallenge(accountPasswordChallenge)
const password = challengeResponse.values[0].value as string
const clientParams = this.getClientKeyParams() as SNRootKeyParams
const serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier)
if (!serverParams || !serverKeyParamsAreSafe(serverParams, clientParams)) {
return
}
const rootKey = await this.protocolService.computeRootKey(password, serverParams)
const signInResponse = await this.userService.correctiveSignIn(rootKey)
if (!signInResponse.error) {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced)
return rootKey
} else {
await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryLoginFlowInvalidPassword)
return this.performServerSignIn()
}
}
private async getWrappingKeyIfApplicable(): Promise<SNRootKey | undefined> {
if (!this.protocolService.hasPasscode()) {
return undefined
}
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable()
if (canceled) {
await this.alertService.alert(
KeyRecoveryStrings.KeyRecoveryPasscodeRequiredText,
KeyRecoveryStrings.KeyRecoveryPasscodeRequiredTitle,
)
return this.getWrappingKeyIfApplicable()
}
return wrappingKey
}
private addKeysToQueue(keys: EncryptedPayloadInterface[]) {
for (const key of keys) {
const keyParams = this.protocolService.getKeyEmbeddedKeyParams(key)
if (!keyParams) {
continue
}
const queueItem: DecryptionQueueItem = {
encryptedKey: key,
keyParams,
}
this.decryptionQueue.push(queueItem)
}
}
private readdQueueItem(queueItem: DecryptionQueueItem) {
this.decryptionQueue.unshift(queueItem)
}
private async getLatestKeyParamsFromServer(identifier: string): Promise<SNRootKeyParams | undefined> {
const paramsResponse = await this.apiService.getAccountKeyParams({
email: identifier,
})
if (!paramsResponse.error && paramsResponse.data) {
return KeyParamsFromApiResponse(paramsResponse as KeyParamsResponse)
} else {
return undefined
}
}
private async beginKeyRecovery() {
if (this.isProcessingQueue) {
return
}
this.isProcessingQueue = true
const clientParams = this.getClientKeyParams()
let serverParams: SNRootKeyParams | undefined = undefined
if (clientParams) {
serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier)
}
const deallocedAfterNetworkRequest = this.protocolService == undefined
if (deallocedAfterNetworkRequest) {
return
}
const credentialsMissing = !this.protocolService.hasAccount() && !this.protocolService.hasPasscode()
if (credentialsMissing) {
const rootKey = await this.performServerSignIn()
if (rootKey) {
const replaceLocalRootKeyWithResult = true
await this.handleDecryptionOfAllKeysMatchingCorrectRootKey(rootKey, replaceLocalRootKeyWithResult, serverParams)
}
}
await this.processQueue(serverParams)
if (serverParams) {
await this.potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams)
}
if (this.syncService.isOutOfSync()) {
void this.syncService.sync({ checkIntegrity: true })
}
}
private async potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams: SNRootKeyParams) {
const latestClientParamsAfterAllRecoveryOperations = this.getClientKeyParams()
if (!latestClientParamsAfterAllRecoveryOperations) {
return
}
const serverParamsDiffer = !serverParams.compare(latestClientParamsAfterAllRecoveryOperations)
if (serverParamsDiffer && serverKeyParamsAreSafe(serverParams, latestClientParamsAfterAllRecoveryOperations)) {
await this.performServerSignIn()
}
}
private async processQueue(serverParams?: SNRootKeyParams): Promise<void> {
let queueItem = this.decryptionQueue[0]
while (queueItem) {
const result = await this.processQueueItem(queueItem, serverParams)
removeFromArray(this.decryptionQueue, queueItem)
if (!isSuccessResult(result) && result.aborted) {
this.isProcessingQueue = false
return
}
queueItem = this.decryptionQueue[0]
}
this.isProcessingQueue = false
}
private async processQueueItem(
queueItem: DecryptionQueueItem,
serverParams?: SNRootKeyParams,
): Promise<KeyRecoveryOperationResult> {
const clientParams = this.getClientKeyParams()
const operation = new KeyRecoveryOperation(
queueItem,
this.itemManager,
this.protocolService,
this.challengeService,
clientParams,
serverParams,
)
const result = await operation.run()
if (!isSuccessResult(result)) {
if (!result.aborted) {
await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryUnableToRecover)
this.readdQueueItem(queueItem)
}
return result
}
await this.handleDecryptionOfAllKeysMatchingCorrectRootKey(
result.rootKey,
result.replaceLocalRootKeyWithResult,
serverParams,
)
return result
}
private async handleDecryptionOfAllKeysMatchingCorrectRootKey(
rootKey: SNRootKey,
replacesRootKey: boolean,
serverParams?: SNRootKeyParams,
): Promise<void> {
if (replacesRootKey) {
const wrappingKey = await this.getWrappingKeyIfApplicable()
await this.protocolService.setRootKey(rootKey, wrappingKey)
}
const clientKeyParams = this.getClientKeyParams()
const clientParamsMatchServer = clientKeyParams && serverParams && clientKeyParams.compare(serverParams)
const matchingKeys = this.removeElementsFromQueueForMatchingKeyParams(rootKey.keyParams).map((qItem) => {
const needsResync = clientParamsMatchServer && !serverParams.compare(qItem.keyParams)
return needsResync
? qItem.encryptedKey.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() })
: qItem.encryptedKey
})
const matchingResults = await this.protocolService.decryptSplit({
usesRootKey: {
items: matchingKeys,
key: rootKey,
},
})
const decryptedMatching = matchingResults.filter(isDecryptedPayload)
void this.payloadManager.emitPayloads(decryptedMatching, PayloadEmitSource.LocalChanged)
await this.storageService.savePayloads(decryptedMatching)
if (replacesRootKey) {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced)
} else {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryKeyRecovered)
}
if (decryptedMatching.some((p) => p.dirty)) {
await this.syncService.sync()
}
await this.notifyEvent(KeyRecoveryEvent.KeysRecovered, decryptedMatching)
void this.removeFromUndecryptables(Uuids(decryptedMatching))
}
private removeElementsFromQueueForMatchingKeyParams(keyParams: SNRootKeyParams) {
const matching = []
const nonmatching = []
for (const queueItem of this.decryptionQueue) {
if (queueItem.keyParams.compare(keyParams)) {
matching.push(queueItem)
} else {
nonmatching.push(queueItem)
}
}
this.decryptionQueue = nonmatching
return matching
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
keyRecovery: {
queueLength: this.decryptionQueue.length,
isProcessingQueue: this.isProcessingQueue,
},
})
}
}

View File

@@ -0,0 +1,36 @@
import { SNRootKeyParams } from '@standardnotes/encryption'
import {
EncryptedTransferPayload,
EncryptedPayloadInterface,
DecryptedPayloadInterface,
ItemsKeyContent,
RootKeyInterface,
} from '@standardnotes/models'
import { UuidString } from '@Lib/Types'
export type UndecryptableItemsStorage = Record<UuidString, EncryptedTransferPayload>
export type KeyRecoveryOperationSuccessResult = {
rootKey: RootKeyInterface
decryptedItemsKey: DecryptedPayloadInterface<ItemsKeyContent>
replaceLocalRootKeyWithResult: boolean
}
export type KeyRecoveryOperationFailResult = {
aborted: boolean
}
export type KeyRecoveryOperationResult = KeyRecoveryOperationSuccessResult | KeyRecoveryOperationFailResult
export function isSuccessResult(x: KeyRecoveryOperationResult): x is KeyRecoveryOperationSuccessResult {
return 'rootKey' in x
}
export type DecryptionQueueItem = {
encryptedKey: EncryptedPayloadInterface
keyParams: SNRootKeyParams
}
export enum KeyRecoveryEvent {
KeysRecovered = 'KeysRecovered',
}

View File

@@ -0,0 +1,6 @@
import { leftVersionGreaterThanOrEqualToRight } from '@standardnotes/common'
import { SNRootKeyParams } from '@standardnotes/encryption'
export function serverKeyParamsAreSafe(serverParams: SNRootKeyParams, clientParams: SNRootKeyParams) {
return leftVersionGreaterThanOrEqualToRight(serverParams.version, clientParams.version)
}

View File

@@ -0,0 +1,9 @@
import { Uuid } from '@standardnotes/common'
import { ListedAccount, ListedAccountInfo } from '@standardnotes/responses'
export interface ListedClientInterface {
canRegisterNewListedAccount: () => boolean
requestNewListedAccount: () => Promise<ListedAccount | undefined>
getListedAccounts(): Promise<ListedAccount[]>
getListedAccountInfo(account: ListedAccount, inContextOfItem?: Uuid): Promise<ListedAccountInfo | undefined>
}

View File

@@ -0,0 +1,121 @@
import { isString, lastElement, sleep } from '@standardnotes/utils'
import { UuidString } from '@Lib/Types/UuidString'
import { ContentType } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { SNHttpService } from '../Api/HttpService'
import { SettingName } from '@standardnotes/settings'
import { SNSettingsService } from '../Settings/SNSettingsService'
import { ListedClientInterface } from './ListedClientInterface'
import { SNApiService } from '../Api/ApiService'
import { ListedAccount, ListedAccountInfo, ListedAccountInfoResponse } from '@standardnotes/responses'
import { SNActionsExtension } from '@standardnotes/models'
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services'
export class ListedService extends AbstractService implements ListedClientInterface {
constructor(
private apiService: SNApiService,
private itemManager: ItemManager,
private settingsService: SNSettingsService,
private httpSerivce: SNHttpService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
override deinit() {
;(this.itemManager as unknown) = undefined
;(this.settingsService as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.httpSerivce as unknown) = undefined
super.deinit()
}
public canRegisterNewListedAccount(): boolean {
return this.apiService.user != undefined
}
/**
* Account creation is asyncronous on the backend due to message-based nature of architecture.
* In order to get the newly created account, we poll the server to check for new accounts.
*/
public async requestNewListedAccount(): Promise<ListedAccount | undefined> {
const accountsBeforeRequest = await this.getSettingsBasedListedAccounts()
const response = await this.apiService.registerForListedAccount()
if (response.error) {
return undefined
}
const MaxAttempts = 4
const DelayBetweenRequests = 3000
for (let i = 0; i < MaxAttempts; i++) {
const accounts = await this.getSettingsBasedListedAccounts()
if (accounts.length > accountsBeforeRequest.length) {
return lastElement(accounts)
} else {
await sleep(DelayBetweenRequests, false)
}
}
return undefined
}
public async getListedAccounts(): Promise<ListedAccount[]> {
const settingsBasedAccounts = await this.getSettingsBasedListedAccounts()
const legacyAccounts = this.getLegacyListedAccounts()
return [...settingsBasedAccounts, ...legacyAccounts]
}
public async getListedAccountInfo(
account: ListedAccount,
inContextOfItem?: UuidString,
): Promise<ListedAccountInfo | undefined> {
const hostUrl = account.hostUrl
let url = `${hostUrl}/authors/${account.authorId}/extension?secret=${account.secret}`
if (inContextOfItem) {
url += `&item_uuid=${inContextOfItem}`
}
const response = (await this.httpSerivce.getAbsolute(url)) as ListedAccountInfoResponse
if (response.error || !response.data || isString(response.data)) {
return undefined
}
return response.data
}
private async getSettingsBasedListedAccounts(): Promise<ListedAccount[]> {
const response = await this.settingsService.getSetting(SettingName.ListedAuthorSecrets)
if (!response) {
return []
}
const accounts = JSON.parse(response) as ListedAccount[]
return accounts
}
private getLegacyListedAccounts(): ListedAccount[] {
const extensions = this.itemManager
.getItems<SNActionsExtension>(ContentType.ActionsExtension)
.filter((extension) => extension.isListedExtension)
const accounts: ListedAccount[] = []
for (const extension of extensions) {
const urlString = extension.url
const url = new URL(urlString)
/** Expected path format: '/authors/647/extension/' */
const path = url.pathname
const authorId = path.split('/')[2]
/** Expected query string format: '?secret=xxx&type=sn&name=Listed' */
const queryString = url.search
const key = queryString.split('secret=')[1].split('&')[0]
accounts.push({
secret: key,
authorId,
hostUrl: url.origin,
})
}
return accounts
}
}

View File

@@ -0,0 +1,2 @@
export * from './ListedClientInterface'
export * from './ListedService'

View File

@@ -0,0 +1,68 @@
import { SettingName } from '@standardnotes/settings'
import { SNSettingsService } from '../Settings'
import * as messages from '../Api/Messages'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SNFeaturesService } from '../Features/FeaturesService'
import { FeatureIdentifier } from '@standardnotes/features'
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services'
export class SNMfaService extends AbstractService {
constructor(
private settingsService: SNSettingsService,
private crypto: PureCryptoInterface,
private featuresService: SNFeaturesService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
private async saveMfaSetting(secret: string): Promise<void> {
return await this.settingsService.updateSetting(SettingName.MfaSecret, secret, true)
}
async isMfaActivated(): Promise<boolean> {
const mfaSetting = await this.settingsService.getDoesSensitiveSettingExist(SettingName.MfaSecret)
return mfaSetting != false
}
async generateMfaSecret(): Promise<string> {
return this.crypto.generateOtpSecret()
}
async getOtpToken(secret: string): Promise<string> {
return this.crypto.totpToken(secret, Date.now(), 6, 30)
}
async enableMfa(secret: string, otpToken: string): Promise<void> {
const otpTokenValid = otpToken != undefined && otpToken === (await this.getOtpToken(secret))
if (!otpTokenValid) {
throw new Error(messages.SignInStrings.IncorrectMfa)
}
return this.saveMfaSetting(secret)
}
async disableMfa(): Promise<void> {
return await this.settingsService.deleteSetting(SettingName.MfaSecret)
}
isMfaFeatureAvailable(): boolean {
const feature = this.featuresService.getUserFeature(FeatureIdentifier.TwoFactorAuth)
// If the feature is not present in the collection, we don't want to block it
if (feature == undefined) {
return false
}
return feature.no_expire === true || (feature.expires_at ?? 0) > Date.now()
}
override deinit(): void {
;(this.settingsService as unknown) = undefined
;(this.crypto as unknown) = undefined
;(this.featuresService as unknown) = undefined
super.deinit()
}
}

View File

@@ -0,0 +1,151 @@
import { ApplicationEvent } from '../../Application/Event'
import { BaseMigration } from '@Lib/Migrations/Base'
import { compareSemVersions } from '@Lib/Version'
import { lastElement } from '@standardnotes/utils'
import { Migration } from '@Lib/Migrations/Migration'
import { MigrationServices } from '../../Migrations/MigrationServices'
import {
RawStorageKey,
namespacedKey,
ApplicationStage,
AbstractService,
DiagnosticInfo,
} from '@standardnotes/services'
import { SnjsVersion, isRightVersionGreaterThanLeft } from '../../Version'
import { SNLog } from '@Lib/Log'
import { MigrationClasses } from '@Lib/Migrations/Versions'
/**
* The migration service orchestrates the execution of multi-stage migrations.
* Migrations are registered during initial application launch, and listen for application
* life-cycle events, and act accordingly. Migrations operate on the app-level, and not global level.
* For example, a single migration may perform a unique set of steps when the application
* first launches, and also other steps after the application is unlocked, or after the
* first sync completes. Migrations live under /migrations and inherit from the base Migration class.
*/
export class SNMigrationService extends AbstractService {
private activeMigrations?: Migration[]
private baseMigration!: BaseMigration
constructor(private services: MigrationServices) {
super(services.internalEventBus)
}
override deinit(): void {
;(this.services as unknown) = undefined
if (this.activeMigrations) {
this.activeMigrations.length = 0
}
super.deinit()
}
public async initialize(): Promise<void> {
await this.runBaseMigrationPreRun()
const requiredMigrations = SNMigrationService.getRequiredMigrations(await this.getStoredSnjsVersion())
this.activeMigrations = this.instantiateMigrationClasses(requiredMigrations)
if (this.activeMigrations.length > 0) {
const lastMigration = lastElement(this.activeMigrations) as Migration
lastMigration.onDone(async () => {
await this.markMigrationsAsDone()
})
} else {
await this.services.deviceInterface.setRawStorageValue(
namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion),
SnjsVersion,
)
}
}
private async markMigrationsAsDone() {
await this.services.deviceInterface.setRawStorageValue(
namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion),
SnjsVersion,
)
}
private async runBaseMigrationPreRun() {
this.baseMigration = new BaseMigration(this.services)
await this.baseMigration.preRun()
}
/**
* Application instances will call this function directly when they arrive
* at a certain migratory state.
*/
public override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
await super.handleApplicationStage(stage)
await this.handleStage(stage)
}
/**
* Called by application
*/
public async handleApplicationEvent(event: ApplicationEvent): Promise<void> {
if (event === ApplicationEvent.SignedIn) {
await this.handleStage(ApplicationStage.SignedIn_30)
}
}
public async hasPendingMigrations(): Promise<boolean> {
const requiredMigrations = SNMigrationService.getRequiredMigrations(await this.getStoredSnjsVersion())
return requiredMigrations.length > 0 || (await this.baseMigration.needsKeychainRepair())
}
public async getStoredSnjsVersion(): Promise<string> {
const version = await this.services.deviceInterface.getRawStorageValue(
namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion),
)
if (!version) {
throw SNLog.error(Error('Snjs version missing from storage, run base migration.'))
}
return version
}
private static getRequiredMigrations(storedVersion: string) {
const resultingClasses = []
const sortedClasses = MigrationClasses.sort((a, b) => {
return compareSemVersions(a.version(), b.version())
})
for (const migrationClass of sortedClasses) {
const migrationVersion = migrationClass.version()
if (migrationVersion === storedVersion) {
continue
}
if (isRightVersionGreaterThanLeft(storedVersion, migrationVersion)) {
resultingClasses.push(migrationClass)
}
}
return resultingClasses
}
private instantiateMigrationClasses(classes: typeof MigrationClasses): Migration[] {
return classes.map((migrationClass) => {
return new migrationClass(this.services)
})
}
private async handleStage(stage: ApplicationStage) {
await this.baseMigration.handleStage(stage)
if (!this.activeMigrations) {
throw new Error('Invalid active migrations')
}
for (const migration of this.activeMigrations) {
await migration.handleStage(stage)
}
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
migrations: {
activeMigrations: this.activeMigrations && this.activeMigrations.map((m) => typeof m),
},
})
}
}

View File

@@ -0,0 +1,183 @@
import { ContentType } from '@standardnotes/common'
import { ChallengeReason, SyncOptions } from '@standardnotes/services'
import { TransactionalMutation } from '../Items'
import * as Models from '@standardnotes/models'
import { ClientDisplayableError } from '@standardnotes/responses'
import { BackupFile } from '@standardnotes/encryption'
export interface MutatorClientInterface {
/**
* Inserts the input item by its payload properties, and marks the item as dirty.
* A sync is not performed after an item is inserted. This must be handled by the caller.
*/
insertItem(item: Models.DecryptedItemInterface): Promise<Models.DecryptedItemInterface>
/**
* Mutates a pre-existing item, marks it as dirty, and syncs it
*/
changeAndSaveItem<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemToLookupUuidFor: Models.DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
emitSource?: Models.PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<Models.DecryptedItemInterface | undefined>
/**
* Mutates pre-existing items, marks them as dirty, and syncs
*/
changeAndSaveItems<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemsToLookupUuidsFor: Models.DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
emitSource?: Models.PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<void>
/**
* Mutates a pre-existing item and marks it as dirty. Does not sync changes.
*/
changeItem<M extends Models.DecryptedItemMutator>(
itemToLookupUuidFor: Models.DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
): Promise<Models.DecryptedItemInterface | undefined>
/**
* Mutates a pre-existing items and marks them as dirty. Does not sync changes.
*/
changeItems<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemsToLookupUuidsFor: Models.DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps?: boolean,
): Promise<(Models.DecryptedItemInterface | undefined)[]>
/**
* Run unique mutations per each item in the array, then only propagate all changes
* once all mutations have been run. This differs from `changeItems` in that changeItems
* runs the same mutation on all items.
*/
runTransactionalMutations(
transactions: TransactionalMutation[],
emitSource?: Models.PayloadEmitSource,
payloadSourceKey?: string,
): Promise<(Models.DecryptedItemInterface | undefined)[]>
runTransactionalMutation(
transaction: TransactionalMutation,
emitSource?: Models.PayloadEmitSource,
payloadSourceKey?: string,
): Promise<Models.DecryptedItemInterface | undefined>
protectItems<
_M extends Models.DecryptedItemMutator<Models.ItemContent>,
I extends Models.DecryptedItemInterface<Models.ItemContent>,
>(
items: I[],
): Promise<I[]>
unprotectItems<
_M extends Models.DecryptedItemMutator<Models.ItemContent>,
I extends Models.DecryptedItemInterface<Models.ItemContent>,
>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined>
protectNote(note: Models.SNNote): Promise<Models.SNNote>
unprotectNote(note: Models.SNNote): Promise<Models.SNNote | undefined>
protectNotes(notes: Models.SNNote[]): Promise<Models.SNNote[]>
unprotectNotes(notes: Models.SNNote[]): Promise<Models.SNNote[]>
protectFile(file: Models.FileItem): Promise<Models.FileItem>
unprotectFile(file: Models.FileItem): Promise<Models.FileItem | undefined>
/**
* Takes the values of the input item and emits it onto global state.
*/
mergeItem(
item: Models.DecryptedItemInterface,
source: Models.PayloadEmitSource,
): Promise<Models.DecryptedItemInterface>
/**
* Creates an unmanaged item that can be added later.
*/
createTemplateItem<
C extends Models.ItemContent = Models.ItemContent,
I extends Models.DecryptedItemInterface<C> = Models.DecryptedItemInterface<C>,
>(
contentType: ContentType,
content?: C,
): I
/**
* @param isUserModified Whether to change the modified date the user
* sees of the item.
*/
setItemNeedsSync(
item: Models.DecryptedItemInterface,
isUserModified?: boolean,
): Promise<Models.DecryptedItemInterface | undefined>
setItemsNeedsSync(items: Models.DecryptedItemInterface[]): Promise<(Models.DecryptedItemInterface | undefined)[]>
deleteItem(item: Models.DecryptedItemInterface | Models.EncryptedItemInterface): Promise<void>
deleteItems(items: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[]): Promise<void>
emptyTrash(): Promise<void>
duplicateItem<T extends Models.DecryptedItemInterface>(item: T, additionalContent?: Partial<T['content']>): Promise<T>
/**
* Migrates any tags containing a '.' character to sa chema-based heirarchy, removing
* the dot from the tag's title.
*/
migrateTagsToFolders(): Promise<unknown>
/**
* Establishes a hierarchical relationship between two tags.
*/
setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise<void>
/**
* Remove the tag parent.
*/
unsetTagParent(childTag: Models.SNTag): Promise<void>
findOrCreateTag(title: string): Promise<Models.SNTag>
/** Creates and returns the tag but does not run sync. Callers must perform sync. */
createTagOrSmartView(title: string): Promise<Models.SNTag | Models.SmartView>
/**
* Activates or deactivates a component, depending on its
* current state, and syncs.
*/
toggleComponent(component: Models.SNComponent): Promise<void>
toggleTheme(theme: Models.SNComponent): Promise<void>
/**
* @returns
* .affectedItems: Items that were either created or dirtied by this import
* .errorCount: The number of items that were not imported due to failure to decrypt.
*/
importData(
data: BackupFile,
awaitSync?: boolean,
): Promise<
| {
affectedItems: Models.DecryptedItemInterface[]
errorCount: number
}
| {
error: ClientDisplayableError
}
>
}

View File

@@ -0,0 +1,83 @@
import { SNHistoryManager } from './../History/HistoryManager'
import { NoteContent, SNNote, FillItemContent, DecryptedPayload, PayloadTimestampDefaults } from '@standardnotes/models'
import { EncryptionService } from '@standardnotes/encryption'
import { ContentType } from '@standardnotes/common'
import { InternalEventBusInterface } from '@standardnotes/services'
import {
ChallengeService,
MutatorService,
PayloadManager,
SNComponentManager,
SNProtectionService,
ItemManager,
SNSyncService,
} from '../'
import { UuidGenerator } from '@standardnotes/utils'
const setupRandomUuid = () => {
UuidGenerator.SetGenerator(() => String(Math.random()))
}
describe('mutator service', () => {
let mutatorService: MutatorService
let payloadManager: PayloadManager
let itemManager: ItemManager
let syncService: SNSyncService
let protectionService: SNProtectionService
let protocolService: EncryptionService
let challengeService: ChallengeService
let componentManager: SNComponentManager
let historyService: SNHistoryManager
let internalEventBus: InternalEventBusInterface
beforeEach(() => {
setupRandomUuid()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
payloadManager = new PayloadManager(internalEventBus)
itemManager = new ItemManager(payloadManager, { supportsFileNavigation: false }, internalEventBus)
mutatorService = new MutatorService(
itemManager,
syncService,
protectionService,
protocolService,
payloadManager,
challengeService,
componentManager,
historyService,
internalEventBus,
)
})
const insertNote = (title: string) => {
const note = new SNNote(
new DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.Note,
content: FillItemContent<NoteContent>({
title: title,
}),
...PayloadTimestampDefaults(),
}),
)
return mutatorService.insertItem(note)
}
describe('note modifications', () => {
it('pinning should not update timestamps', async () => {
const note = await insertNote('hello')
const pinnedNote = await mutatorService.changeItem(
note,
(mutator) => {
mutator.pinned = true
},
false,
)
expect(note.userModifiedDate).toEqual(pinnedNote?.userModifiedDate)
})
})
})

View File

@@ -0,0 +1,386 @@
import { SNHistoryManager } from './../History/HistoryManager'
import {
AbstractService,
InternalEventBusInterface,
SyncOptions,
ChallengeValidation,
ChallengePrompt,
ChallengeReason,
} from '@standardnotes/services'
import { BackupFile, EncryptionProvider } from '@standardnotes/encryption'
import { ClientDisplayableError } from '@standardnotes/responses'
import { ContentType, ProtocolVersion, compareVersions } from '@standardnotes/common'
import { ItemManager, TransactionalMutation } from '../Items'
import { MutatorClientInterface } from './MutatorClientInterface'
import { PayloadManager } from '../Payloads/PayloadManager'
import { SNComponentManager } from '../ComponentManager/ComponentManager'
import { SNProtectionService } from '../Protection/ProtectionService'
import { SNSyncService } from '../Sync'
import { Strings } from '../../Strings'
import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders'
import * as Models from '@standardnotes/models'
import { Challenge, ChallengeService } from '../Challenge'
import {
CreateDecryptedBackupFileContextPayload,
CreateEncryptedBackupFileContextPayload,
isDecryptedPayload,
isEncryptedTransferPayload,
} from '@standardnotes/models'
export class MutatorService extends AbstractService implements MutatorClientInterface {
constructor(
private itemManager: ItemManager,
private syncService: SNSyncService,
private protectionService: SNProtectionService,
private encryption: EncryptionProvider,
private payloadManager: PayloadManager,
private challengeService: ChallengeService,
private componentManager: SNComponentManager,
private historyService: SNHistoryManager,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
override deinit() {
super.deinit()
;(this.itemManager as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.protectionService as unknown) = undefined
;(this.encryption as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.componentManager as unknown) = undefined
;(this.historyService as unknown) = undefined
}
public async insertItem(item: Models.DecryptedItemInterface): Promise<Models.DecryptedItemInterface> {
const mutator = Models.CreateDecryptedMutatorForItem(item, Models.MutationType.UpdateUserTimestamps)
const dirtiedPayload = mutator.getResult()
const insertedItem = await this.itemManager.emitItemFromPayload(
dirtiedPayload,
Models.PayloadEmitSource.LocalInserted,
)
return insertedItem
}
public async changeAndSaveItem<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemToLookupUuidFor: Models.DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: Models.PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<Models.DecryptedItemInterface | undefined> {
await this.itemManager.changeItems(
[itemToLookupUuidFor],
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
emitSource,
)
await this.syncService.sync(syncOptions)
return this.itemManager.findItem(itemToLookupUuidFor.uuid)
}
public async changeAndSaveItems<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemsToLookupUuidsFor: Models.DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps = true,
emitSource?: Models.PayloadEmitSource,
syncOptions?: SyncOptions,
): Promise<void> {
await this.itemManager.changeItems(
itemsToLookupUuidsFor,
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
emitSource,
)
await this.syncService.sync(syncOptions)
}
public async changeItem<M extends Models.DecryptedItemMutator>(
itemToLookupUuidFor: Models.DecryptedItemInterface,
mutate: (mutator: M) => void,
updateTimestamps = true,
): Promise<Models.DecryptedItemInterface | undefined> {
await this.itemManager.changeItems(
[itemToLookupUuidFor],
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
)
return this.itemManager.findItem(itemToLookupUuidFor.uuid)
}
public async changeItems<M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator>(
itemsToLookupUuidsFor: Models.DecryptedItemInterface[],
mutate: (mutator: M) => void,
updateTimestamps = true,
): Promise<(Models.DecryptedItemInterface | undefined)[]> {
return this.itemManager.changeItems(
itemsToLookupUuidsFor,
mutate,
updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps,
)
}
public async runTransactionalMutations(
transactions: TransactionalMutation[],
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<(Models.DecryptedItemInterface | undefined)[]> {
return this.itemManager.runTransactionalMutations(transactions, emitSource, payloadSourceKey)
}
public async runTransactionalMutation(
transaction: TransactionalMutation,
emitSource = Models.PayloadEmitSource.LocalChanged,
payloadSourceKey?: string,
): Promise<Models.DecryptedItemInterface | undefined> {
return this.itemManager.runTransactionalMutation(transaction, emitSource, payloadSourceKey)
}
async protectItems<M extends Models.DecryptedItemMutator, I extends Models.DecryptedItemInterface>(
items: I[],
): Promise<I[]> {
const protectedItems = await this.itemManager.changeItems<M, I>(
items,
(mutator) => {
mutator.protected = true
},
Models.MutationType.NoUpdateUserTimestamps,
)
void this.syncService.sync()
return protectedItems
}
async unprotectItems<M extends Models.DecryptedItemMutator, I extends Models.DecryptedItemInterface>(
items: I[],
reason: ChallengeReason,
): Promise<I[] | undefined> {
if (!(await this.protectionService.authorizeAction(reason))) {
return undefined
}
const unprotectedItems = await this.itemManager.changeItems<M, I>(
items,
(mutator) => {
mutator.protected = false
},
Models.MutationType.NoUpdateUserTimestamps,
)
void this.syncService.sync()
return unprotectedItems
}
public async protectNote(note: Models.SNNote): Promise<Models.SNNote> {
const result = await this.protectItems([note])
return result[0]
}
public async unprotectNote(note: Models.SNNote): Promise<Models.SNNote | undefined> {
const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote)
return result ? result[0] : undefined
}
public async protectNotes(notes: Models.SNNote[]): Promise<Models.SNNote[]> {
return this.protectItems(notes)
}
public async unprotectNotes(notes: Models.SNNote[]): Promise<Models.SNNote[]> {
const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote)
return results || []
}
async protectFile(file: Models.FileItem): Promise<Models.FileItem> {
const result = await this.protectItems([file])
return result[0]
}
async unprotectFile(file: Models.FileItem): Promise<Models.FileItem | undefined> {
const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile)
return result ? result[0] : undefined
}
public async mergeItem(
item: Models.DecryptedItemInterface,
source: Models.PayloadEmitSource,
): Promise<Models.DecryptedItemInterface> {
return this.itemManager.emitItemFromPayload(item.payloadRepresentation(), source)
}
public createTemplateItem<
C extends Models.ItemContent = Models.ItemContent,
I extends Models.DecryptedItemInterface<C> = Models.DecryptedItemInterface<C>,
>(contentType: ContentType, content?: C): I {
return this.itemManager.createTemplateItem(contentType, content)
}
public async setItemNeedsSync(
item: Models.DecryptedItemInterface,
updateTimestamps = false,
): Promise<Models.DecryptedItemInterface | undefined> {
return this.itemManager.setItemDirty(item, updateTimestamps)
}
public async setItemsNeedsSync(
items: Models.DecryptedItemInterface[],
): Promise<(Models.DecryptedItemInterface | undefined)[]> {
return this.itemManager.setItemsDirty(items)
}
public async deleteItem(item: Models.DecryptedItemInterface | Models.EncryptedItemInterface): Promise<void> {
return this.deleteItems([item])
}
public async deleteItems(items: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[]): Promise<void> {
await this.itemManager.setItemsToBeDeleted(items)
await this.syncService.sync()
}
public async emptyTrash(): Promise<void> {
await this.itemManager.emptyTrash()
await this.syncService.sync()
}
public duplicateItem<T extends Models.DecryptedItemInterface>(
item: T,
additionalContent?: Partial<T['content']>,
): Promise<T> {
const duplicate = this.itemManager.duplicateItem<T>(item, false, additionalContent)
void this.syncService.sync()
return duplicate
}
public async migrateTagsToFolders(): Promise<unknown> {
await TagsToFoldersMigrationApplicator.run(this.itemManager)
return this.syncService.sync()
}
public async setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise<void> {
await this.itemManager.setTagParent(parentTag, childTag)
}
public async unsetTagParent(childTag: Models.SNTag): Promise<void> {
await this.itemManager.unsetTagParent(childTag)
}
public async findOrCreateTag(title: string): Promise<Models.SNTag> {
return this.itemManager.findOrCreateTagByTitle(title)
}
/** Creates and returns the tag but does not run sync. Callers must perform sync. */
public async createTagOrSmartView(title: string): Promise<Models.SNTag | Models.SmartView> {
return this.itemManager.createTagOrSmartView(title)
}
public async toggleComponent(component: Models.SNComponent): Promise<void> {
await this.componentManager.toggleComponent(component.uuid)
await this.syncService.sync()
}
public async toggleTheme(theme: Models.SNComponent): Promise<void> {
await this.componentManager.toggleTheme(theme.uuid)
await this.syncService.sync()
}
public async importData(
data: BackupFile,
awaitSync = false,
): Promise<
| {
affectedItems: Models.DecryptedItemInterface[]
errorCount: number
}
| {
error: ClientDisplayableError
}
> {
if (data.version) {
/**
* Prior to 003 backup files did not have a version field so we cannot
* stop importing if there is no backup file version, only if there is
* an unsupported version.
*/
const version = data.version as ProtocolVersion
const supportedVersions = this.encryption.supportedVersions()
if (!supportedVersions.includes(version)) {
return { error: new ClientDisplayableError(Strings.Info.UnsupportedBackupFileVersion) }
}
const userVersion = this.encryption.getUserVersion()
if (userVersion && compareVersions(version, userVersion) === 1) {
/** File was made with a greater version than the user's account */
return { error: new ClientDisplayableError(Strings.Info.BackupFileMoreRecentThanAccount) }
}
}
let password: string | undefined
if (data.auth_params || data.keyParams) {
/** Get import file password. */
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, Strings.Input.FileAccountPassword, undefined, true)],
ChallengeReason.DecryptEncryptedFile,
true,
)
const passwordResponse = await this.challengeService.promptForChallengeResponse(challenge)
if (passwordResponse == undefined) {
/** Challenge was canceled */
return { error: new ClientDisplayableError('Import aborted') }
}
this.challengeService.completeChallenge(challenge)
password = passwordResponse?.values[0].value as string
}
if (!(await this.protectionService.authorizeFileImport())) {
return { error: new ClientDisplayableError('Import aborted') }
}
data.items = data.items.map((item) => {
if (isEncryptedTransferPayload(item)) {
return CreateEncryptedBackupFileContextPayload(item)
} else {
return CreateDecryptedBackupFileContextPayload(item as Models.BackupFileDecryptedContextualPayload)
}
})
const decryptedPayloadsOrError = await this.encryption.decryptBackupFile(data, password)
if (decryptedPayloadsOrError instanceof ClientDisplayableError) {
return { error: decryptedPayloadsOrError }
}
const validPayloads = decryptedPayloadsOrError.filter(isDecryptedPayload).map((payload) => {
/* Don't want to activate any components during import process in
* case of exceptions breaking up the import proccess */
if (payload.content_type === ContentType.Component && (payload.content as Models.ComponentContent).active) {
const typedContent = payload as Models.DecryptedPayloadInterface<Models.ComponentContent>
return Models.CopyPayloadWithContentOverride(typedContent, {
active: false,
})
} else {
return payload
}
})
const affectedUuids = await this.payloadManager.importPayloads(
validPayloads,
this.historyService.getHistoryMapCopy(),
)
const promise = this.syncService.sync()
if (awaitSync) {
await promise
}
const affectedItems = this.itemManager.findItems(affectedUuids) as Models.DecryptedItemInterface[]
return {
affectedItems: affectedItems,
errorCount: decryptedPayloadsOrError.length - validPayloads.length,
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './MutatorClientInterface'
export * from './MutatorService'

View File

@@ -0,0 +1,53 @@
import {
DecryptedPayload,
FillItemContent,
ItemsKeyContent,
PayloadEmitSource,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { PayloadManager } from './PayloadManager'
import { InternalEventBusInterface } from '@standardnotes/services'
import { ContentType } from '@standardnotes/common'
describe('payload manager', () => {
let payloadManager: PayloadManager
let internalEventBus: InternalEventBusInterface
beforeEach(() => {
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
payloadManager = new PayloadManager(internalEventBus)
})
it('emitting a payload should emit as-is and not merge on top of existing payload', async () => {
const decrypted = new DecryptedPayload({
uuid: '123',
content_type: ContentType.ItemsKey,
content: FillItemContent<ItemsKeyContent>({
itemsKey: 'secret',
}),
...PayloadTimestampDefaults(),
updated_at_timestamp: 1,
dirty: true,
})
await payloadManager.emitPayload(decrypted, PayloadEmitSource.LocalInserted)
const nondirty = new DecryptedPayload({
uuid: '123',
content_type: ContentType.ItemsKey,
...PayloadTimestampDefaults(),
updated_at_timestamp: 2,
content: FillItemContent<ItemsKeyContent>({
itemsKey: 'secret',
}),
})
await payloadManager.emitPayload(nondirty, PayloadEmitSource.LocalChanged)
const result = payloadManager.findOne('123')
expect(result?.dirty).toBeFalsy()
})
})

View File

@@ -0,0 +1,338 @@
import { ContentType, Uuid } from '@standardnotes/common'
import { PayloadsChangeObserver, QueueElement, PayloadsChangeObserverCallback, EmitQueue } from './Types'
import { removeFromArray, Uuids } from '@standardnotes/utils'
import {
DeltaFileImport,
isDeletedPayload,
ImmutablePayloadCollection,
EncryptedPayloadInterface,
PayloadSource,
DeletedPayloadInterface,
DecryptedPayloadInterface,
PayloadCollection,
PayloadEmitSource,
DeletedPayload,
FullyFormedPayloadInterface,
isEncryptedPayload,
isDecryptedPayload,
HistoryMap,
DeltaEmit,
getIncrementedDirtyIndex,
} from '@standardnotes/models'
import {
AbstractService,
PayloadManagerInterface,
InternalEventBusInterface,
DiagnosticInfo,
} from '@standardnotes/services'
import { IntegrityPayload } from '@standardnotes/responses'
/**
* The payload manager is responsible for keeping state regarding what items exist in the
* global application state. It does so by exposing functions that allow consumers to 'map'
* a detached payload into global application state. Whenever a change is made or retrieved
* from any source, it must be mapped in order to be properly reflected in global application state.
* The model manager deals only with in-memory state, and does not deal directly with storage.
* It also serves as a query store, and can be queried for current notes, tags, etc.
* It exposes methods that allow consumers to listen to mapping events. This is how
* applications 'stream' items to display in the interface.
*/
export class PayloadManager extends AbstractService implements PayloadManagerInterface {
private changeObservers: PayloadsChangeObserver[] = []
public collection: PayloadCollection<FullyFormedPayloadInterface>
private emitQueue: EmitQueue<FullyFormedPayloadInterface> = []
constructor(protected override internalEventBus: InternalEventBusInterface) {
super(internalEventBus)
this.collection = new PayloadCollection()
}
/**
* Our payload collection keeps the latest mapped payload for every payload
* that passes through our mapping function. Use this to query current state
* as needed to make decisions, like about duplication or uuid alteration.
*/
public getMasterCollection() {
return ImmutablePayloadCollection.FromCollection(this.collection)
}
public override deinit() {
super.deinit()
this.changeObservers.length = 0
this.resetState()
}
public resetState() {
this.collection = new PayloadCollection()
}
public find(uuids: Uuid[]): FullyFormedPayloadInterface[] {
return this.collection.findAll(uuids)
}
public findOne(uuid: Uuid): FullyFormedPayloadInterface | undefined {
return this.collection.findAll([uuid])[0]
}
public all(contentType: ContentType): FullyFormedPayloadInterface[] {
return this.collection.all(contentType)
}
public get integrityPayloads(): IntegrityPayload[] {
return this.collection.integrityPayloads()
}
public get nonDeletedItems(): FullyFormedPayloadInterface[] {
return this.collection.nondeletedElements()
}
public get invalidPayloads(): EncryptedPayloadInterface[] {
return this.collection.invalidElements()
}
public async emitDeltaEmit<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface>(
emit: DeltaEmit<P>,
sourceKey?: string,
): Promise<P[]> {
if (emit.emits.length === 0 && emit.ignored?.length === 0) {
return []
}
return new Promise((resolve) => {
const element: QueueElement<P> = {
emit: emit,
sourceKey,
resolve,
}
this.emitQueue.push(element as unknown as QueueElement<FullyFormedPayloadInterface>)
if (this.emitQueue.length === 1) {
void this.popQueue()
}
})
}
/**
* One of many mapping helpers available.
* This function maps a payload to an item
* @returns every paylod altered as a result of this operation, to be
* saved to storage by the caller
*/
public async emitPayload<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface>(
payload: P,
source: PayloadEmitSource,
sourceKey?: string,
): Promise<P[]> {
return this.emitPayloads([payload], source, sourceKey)
}
/**
* This function maps multiple payloads to items, and is the authoratative mapping
* function that all other mapping helpers rely on
* @returns every paylod altered as a result of this operation, to be
* saved to storage by the caller
*/
public async emitPayloads<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface>(
payloads: P[],
source: PayloadEmitSource,
sourceKey?: string,
): Promise<P[]> {
const emit: DeltaEmit<P> = {
emits: payloads,
source: source,
}
return this.emitDeltaEmit(emit, sourceKey)
}
private popQueue() {
const first = this.emitQueue[0]
const { changed, inserted, discarded, unerrored } = this.applyPayloads(first.emit.emits)
this.notifyChangeObservers(
changed,
inserted,
discarded,
first.emit.ignored || [],
unerrored,
first.emit.source,
first.sourceKey,
)
removeFromArray(this.emitQueue, first)
first.resolve([...changed, ...inserted, ...discarded])
if (this.emitQueue.length > 0) {
void this.popQueue()
}
}
private applyPayloads(applyPayloads: FullyFormedPayloadInterface[]) {
const changed: FullyFormedPayloadInterface[] = []
const inserted: FullyFormedPayloadInterface[] = []
const discarded: DeletedPayloadInterface[] = []
const unerrored: DecryptedPayloadInterface[] = []
for (const apply of applyPayloads) {
if (!apply.uuid || !apply.content_type) {
console.error('Payload is corrupt', apply)
continue
}
this.log(
'applying payload',
apply.uuid,
'globalDirtyIndexAtLastSync',
apply.globalDirtyIndexAtLastSync,
'dirtyIndex',
apply.dirtyIndex,
'dirty',
apply.dirty,
)
const base = this.collection.find(apply.uuid)
if (isDeletedPayload(apply) && apply.discardable) {
this.collection.discard(apply)
discarded.push(apply)
} else {
this.collection.set(apply)
if (base) {
changed.push(apply)
if (isEncryptedPayload(base) && isDecryptedPayload(apply)) {
unerrored.push(apply)
}
} else {
inserted.push(apply)
}
}
}
return { changed, inserted, discarded, unerrored }
}
/**
* Notifies observers when an item has been mapped.
* @param types - An array of content types to listen for
* @param priority - The lower the priority, the earlier the function is called
* wrt to other observers
*/
public addObserver(types: ContentType | ContentType[], callback: PayloadsChangeObserverCallback, priority = 1) {
if (!Array.isArray(types)) {
types = [types]
}
const observer: PayloadsChangeObserver = {
types,
priority,
callback,
}
this.changeObservers.push(observer)
const thislessChangeObservers = this.changeObservers
return () => {
removeFromArray(thislessChangeObservers, observer)
}
}
/**
* This function is mostly for internal use, but can be used externally by consumers who
* explicitely understand what they are doing (want to propagate model state without mapping)
*/
public notifyChangeObservers(
changed: FullyFormedPayloadInterface[],
inserted: FullyFormedPayloadInterface[],
discarded: DeletedPayloadInterface[],
ignored: EncryptedPayloadInterface[],
unerrored: DecryptedPayloadInterface[],
source: PayloadEmitSource,
sourceKey?: string,
) {
/** Slice the observers array as sort modifies in-place */
const observers = this.changeObservers.slice().sort((a, b) => {
return a.priority < b.priority ? -1 : 1
})
const filter = <P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface>(
payloads: P[],
types: ContentType[],
) => {
return types.includes(ContentType.Any)
? payloads.slice()
: payloads.slice().filter((payload) => {
return types.includes(payload.content_type)
})
}
for (const observer of observers) {
observer.callback({
changed: filter(changed, observer.types),
inserted: filter(inserted, observer.types),
discarded: filter(discarded, observer.types),
ignored: filter(ignored, observer.types),
unerrored: filter(unerrored, observer.types),
source,
sourceKey,
})
}
}
/**
* Imports an array of payloads from an external source (such as a backup file)
* and marks the items as dirty.
* @returns Resulting items
*/
public async importPayloads(payloads: DecryptedPayloadInterface[], historyMap: HistoryMap): Promise<Uuid[]> {
const sourcedPayloads = payloads.map((p) => p.copy(undefined, PayloadSource.FileImport))
const delta = new DeltaFileImport(this.getMasterCollection(), sourcedPayloads, historyMap)
const emit = delta.result()
await this.emitDeltaEmit(emit)
return Uuids(payloads)
}
public removePayloadLocally(payload: FullyFormedPayloadInterface) {
this.collection.discard(payload)
}
public erroredPayloadsForContentType(contentType: ContentType): EncryptedPayloadInterface[] {
return this.collection.invalidElements().filter((p) => p.content_type === contentType)
}
public async deleteErroredPayloads(payloads: EncryptedPayloadInterface[]): Promise<void> {
const deleted = payloads.map(
(payload) =>
new DeletedPayload(
{
...payload.ejected(),
deleted: true,
content: undefined,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
},
payload.source,
),
)
await this.emitPayloads(deleted, PayloadEmitSource.LocalChanged)
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
payloads: {
integrityPayloads: this.integrityPayloads,
nonDeletedItemCount: this.nonDeletedItems.length,
invalidPayloadsCount: this.invalidPayloads.length,
},
})
}
}

View File

@@ -0,0 +1,46 @@
import { ContentType } from '@standardnotes/common'
import {
DecryptedPayloadInterface,
DeletedPayloadInterface,
DeltaEmit,
EncryptedPayloadInterface,
FullyFormedPayloadInterface,
PayloadEmitSource,
} from '@standardnotes/models'
export type EmitQueue<P extends FullyFormedPayloadInterface> = QueueElement<P>[]
export type PayloadManagerChangeData = {
/** The payloads are pre-existing but have been changed */
changed: FullyFormedPayloadInterface[]
/** The payloads have been newly inserted */
inserted: FullyFormedPayloadInterface[]
/** The payloads have been deleted from local state (and remote state if applicable) */
discarded: DeletedPayloadInterface[]
/** Payloads for which encrypted overwrite protection is enabled and enacted */
ignored: EncryptedPayloadInterface[]
/** Payloads which were previously error decrypting but now successfully decrypted */
unerrored: DecryptedPayloadInterface[]
source: PayloadEmitSource
sourceKey?: string
}
export type PayloadsChangeObserverCallback = (data: PayloadManagerChangeData) => void
export type PayloadsChangeObserver = {
types: ContentType[]
callback: PayloadsChangeObserverCallback
priority: number
}
export type QueueElement<P extends FullyFormedPayloadInterface = FullyFormedPayloadInterface> = {
emit: DeltaEmit
sourceKey?: string
resolve: (alteredPayloads: P[]) => void
}

View File

@@ -0,0 +1,2 @@
export * from './PayloadManager'
export * from './Types'

View File

@@ -0,0 +1,116 @@
import { SNUserPrefs, PrefKey, PrefValue, UserPrefsMutator, ItemContent, FillItemContent } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { ItemManager } from '../Items/ItemManager'
import { SNSingletonManager } from '../Singleton/SingletonManager'
import { SNSyncService } from '../Sync/SyncService'
import {
AbstractService,
InternalEventBusInterface,
SyncEvent,
ApplicationStage,
PreferenceServiceInterface,
PreferencesServiceEvent,
} from '@standardnotes/services'
export class SNPreferencesService
extends AbstractService<PreferencesServiceEvent>
implements PreferenceServiceInterface
{
private shouldReload = true
private reloading = false
private preferences?: SNUserPrefs
private removeItemObserver?: () => void
private removeSyncObserver?: () => void
constructor(
private singletonManager: SNSingletonManager,
private itemManager: ItemManager,
private syncService: SNSyncService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.removeItemObserver = itemManager.addObserver(ContentType.UserPrefs, () => {
this.shouldReload = true
})
this.removeSyncObserver = syncService.addEventObserver((event) => {
if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) {
void this.reload()
}
})
}
override deinit(): void {
this.removeItemObserver?.()
this.removeSyncObserver?.()
;(this.singletonManager as unknown) = undefined
;(this.itemManager as unknown) = undefined
super.deinit()
}
public override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
await super.handleApplicationStage(stage)
if (stage === ApplicationStage.LoadedDatabase_12) {
/** Try to read preferences singleton from storage */
this.preferences = this.singletonManager.findSingleton<SNUserPrefs>(
ContentType.UserPrefs,
SNUserPrefs.singletonPredicate,
)
if (this.preferences) {
void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged)
}
}
}
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K]): PrefValue[K]
getValue<K extends PrefKey>(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined {
return this.preferences?.getPref(key) ?? defaultValue
}
async setValue<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void> {
if (!this.preferences) {
return
}
this.preferences = (await this.itemManager.changeItem<UserPrefsMutator>(this.preferences, (m) => {
m.setPref(key, value)
})) as SNUserPrefs
void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged)
void this.syncService.sync()
}
private async reload() {
if (!this.shouldReload || this.reloading) {
return
}
this.reloading = true
try {
const previousRef = this.preferences
this.preferences = await this.singletonManager.findOrCreateContentTypeSingleton<ItemContent, SNUserPrefs>(
ContentType.UserPrefs,
FillItemContent({}),
)
if (
previousRef?.uuid !== this.preferences.uuid ||
this.preferences.userModifiedDate > previousRef.userModifiedDate
) {
void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged)
}
this.shouldReload = false
} finally {
this.reloading = false
}
}
}

View File

@@ -0,0 +1,8 @@
import { ChallengeReason } from '@standardnotes/services'
import { DecryptedItem } from '@standardnotes/models'
export interface ProtectionsClientInterface {
authorizeProtectedActionForItems<T extends DecryptedItem>(files: T[], challengeReason: ChallengeReason): Promise<T[]>
authorizeItemAccess(item: DecryptedItem): Promise<boolean>
}

View File

@@ -0,0 +1,110 @@
import { ChallengeService } from '../Challenge'
import { EncryptionService } from '@standardnotes/encryption'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNProtectionService } from './ProtectionService'
import { InternalEventBus, InternalEventBusInterface, ChallengeReason } from '@standardnotes/services'
import { UuidGenerator } from '@standardnotes/utils'
import {
DecryptedPayload,
FileContent,
FileItem,
FillItemContent,
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
const setupRandomUuid = () => {
UuidGenerator.SetGenerator(() => String(Math.random()))
}
describe('protectionService', () => {
let protocolService: EncryptionService
let challengeService: ChallengeService
let storageService: DiskStorageService
let internalEventBus: InternalEventBusInterface
let protectionService: SNProtectionService
const createService = () => {
return new SNProtectionService(protocolService, challengeService, storageService, internalEventBus)
}
const createFile = (name: string, isProtected?: boolean) => {
return new FileItem(
new DecryptedPayload({
uuid: String(Math.random()),
content_type: ContentType.File,
content: FillItemContent<FileContent>({
name: name,
protected: isProtected,
}),
...PayloadTimestampDefaults(),
}),
)
}
beforeEach(() => {
setupRandomUuid()
internalEventBus = {} as jest.Mocked<InternalEventBus>
challengeService = {} as jest.Mocked<ChallengeService>
challengeService.promptForChallengeResponse = jest.fn()
storageService = {} as jest.Mocked<DiskStorageService>
storageService.getValue = jest.fn()
protocolService = {} as jest.Mocked<EncryptionService>
protocolService.hasAccount = jest.fn().mockReturnValue(true)
protocolService.hasPasscode = jest.fn().mockReturnValue(false)
})
describe('files', () => {
it('unprotected file should not require auth', async () => {
protectionService = createService()
const unprotectedFile = createFile('protected.txt', false)
await protectionService.authorizeProtectedActionForItems([unprotectedFile], ChallengeReason.AccessProtectedFile)
expect(challengeService.promptForChallengeResponse).not.toHaveBeenCalled()
})
it('protected file should require auth', async () => {
protectionService = createService()
const protectedFile = createFile('protected.txt', true)
await protectionService.authorizeProtectedActionForItems([protectedFile], ChallengeReason.AccessProtectedFile)
expect(challengeService.promptForChallengeResponse).toHaveBeenCalled()
})
it('array of files having one protected should require auth', async () => {
protectionService = createService()
const protectedFile = createFile('protected.txt', true)
const unprotectedFile = createFile('unprotected.txt', false)
await protectionService.authorizeProtectedActionForItems(
[protectedFile, unprotectedFile],
ChallengeReason.AccessProtectedFile,
)
expect(challengeService.promptForChallengeResponse).toHaveBeenCalled()
})
it('array of files having none protected should not require auth', async () => {
protectionService = createService()
const protectedFile = createFile('protected.txt', false)
const unprotectedFile = createFile('unprotected.txt', false)
await protectionService.authorizeProtectedActionForItems(
[protectedFile, unprotectedFile],
ChallengeReason.AccessProtectedFile,
)
expect(challengeService.promptForChallengeResponse).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,339 @@
import { Challenge } from './../Challenge/Challenge'
import { ChallengeService } from './../Challenge/ChallengeService'
import { SNLog } from '@Lib/Log'
import { DecryptedItem } from '@standardnotes/models'
import { EncryptionService } from '@standardnotes/encryption'
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
import { isNullOrUndefined } from '@standardnotes/utils'
import {
AbstractService,
InternalEventBusInterface,
StorageValueModes,
ApplicationStage,
StorageKey,
DiagnosticInfo,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
} from '@standardnotes/services'
import { ProtectionsClientInterface } from './ClientInterface'
import { ContentType } from '@standardnotes/common'
export enum ProtectionEvent {
UnprotectedSessionBegan = 'UnprotectedSessionBegan',
UnprotectedSessionExpired = 'UnprotectedSessionExpired',
}
export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30
export enum UnprotectedAccessSecondsDuration {
OneMinute = 60,
FiveMinutes = 300,
OneHour = 3600,
OneWeek = 604800,
}
export function isValidProtectionSessionLength(number: unknown): boolean {
return typeof number === 'number' && Object.values(UnprotectedAccessSecondsDuration).includes(number)
}
export const ProtectionSessionDurations = [
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneMinute,
label: '1 Minute',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.FiveMinutes,
label: '5 Minutes',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneHour,
label: '1 Hour',
},
{
valueInSeconds: UnprotectedAccessSecondsDuration.OneWeek,
label: '1 Week',
},
]
/**
* Enforces certain actions to require extra authentication,
* like viewing a protected note, as well as managing how long that
* authentication should be valid for.
*/
export class SNProtectionService extends AbstractService<ProtectionEvent> implements ProtectionsClientInterface {
private sessionExpiryTimeout = -1
constructor(
private protocolService: EncryptionService,
private challengeService: ChallengeService,
private storageService: DiskStorageService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public override deinit(): void {
clearTimeout(this.sessionExpiryTimeout)
;(this.protocolService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.storageService as unknown) = undefined
super.deinit()
}
override handleApplicationStage(stage: ApplicationStage): Promise<void> {
if (stage === ApplicationStage.LoadedDatabase_12) {
this.updateSessionExpiryTimer(this.getSessionExpiryDate())
}
return Promise.resolve()
}
public hasProtectionSources(): boolean {
return this.protocolService.hasAccount() || this.protocolService.hasPasscode() || this.hasBiometricsEnabled()
}
public hasUnprotectedAccessSession(): boolean {
if (!this.hasProtectionSources()) {
return true
}
return this.getSessionExpiryDate() > new Date()
}
public hasBiometricsEnabled(): boolean {
const biometricsState = this.storageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped)
return Boolean(biometricsState)
}
public enableBiometrics(): boolean {
if (this.hasBiometricsEnabled()) {
SNLog.onError(Error('Tried to enable biometrics when they already are enabled.'))
return false
}
this.storageService.setValue(StorageKey.BiometricsState, true, StorageValueModes.Nonwrapped)
return true
}
public async disableBiometrics(): Promise<boolean> {
if (!this.hasBiometricsEnabled()) {
SNLog.onError(Error('Tried to disable biometrics when they already are disabled.'))
return false
}
if (await this.validateOrRenewSession(ChallengeReason.DisableBiometrics)) {
this.storageService.setValue(StorageKey.BiometricsState, false, StorageValueModes.Nonwrapped)
return true
} else {
return false
}
}
public createLaunchChallenge(): Challenge | undefined {
const prompts: ChallengePrompt[] = []
if (this.hasBiometricsEnabled()) {
prompts.push(new ChallengePrompt(ChallengeValidation.Biometric))
}
if (this.protocolService.hasPasscode()) {
prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode))
}
if (prompts.length > 0) {
return new Challenge(prompts, ChallengeReason.ApplicationUnlock, false)
} else {
return undefined
}
}
async authorizeProtectedActionForItems<T extends DecryptedItem>(
items: T[],
challengeReason: ChallengeReason,
): Promise<T[]> {
let sessionValidation: Promise<boolean> | undefined
const authorizedItems = []
for (const item of items) {
const needsAuthorization = item.protected && !this.hasUnprotectedAccessSession()
if (needsAuthorization && !sessionValidation) {
sessionValidation = this.validateOrRenewSession(challengeReason)
}
if (!needsAuthorization || (await sessionValidation)) {
authorizedItems.push(item)
}
}
return authorizedItems
}
async authorizeItemAccess(item: DecryptedItem): Promise<boolean> {
if (!item.protected) {
return true
}
return this.authorizeAction(
item.content_type === ContentType.Note
? ChallengeReason.AccessProtectedNote
: ChallengeReason.AccessProtectedFile,
)
}
authorizeAddingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.AddPasscode)
}
authorizeChangingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ChangePasscode)
}
authorizeRemovingPasscode(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.RemovePasscode)
}
authorizeSearchingProtectedNotesText(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.SearchProtectedNotesText)
}
authorizeFileImport(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ImportFile)
}
async authorizeBackupCreation(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ExportBackup, {
fallBackToAccountPassword: true,
})
}
async authorizeMfaDisable(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.DisableMfa, {
requireAccountPassword: true,
})
}
async authorizeAutolockIntervalChange(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.ChangeAutolockInterval)
}
async authorizeSessionRevoking(): Promise<boolean> {
return this.authorizeAction(ChallengeReason.RevokeSession)
}
async authorizeAction(
reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false } = {},
): Promise<boolean> {
return this.validateOrRenewSession(reason, {
requireAccountPassword,
fallBackToAccountPassword,
})
}
private async validateOrRenewSession(
reason: ChallengeReason,
{ fallBackToAccountPassword = true, requireAccountPassword = false } = {},
): Promise<boolean> {
if (this.getSessionExpiryDate() > new Date()) {
return true
}
const prompts: ChallengePrompt[] = []
if (this.hasBiometricsEnabled()) {
prompts.push(new ChallengePrompt(ChallengeValidation.Biometric))
}
if (this.protocolService.hasPasscode()) {
prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode))
}
if (requireAccountPassword) {
if (!this.protocolService.hasAccount()) {
throw Error('Requiring account password for challenge with no account')
}
prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword))
}
if (prompts.length === 0) {
if (fallBackToAccountPassword && this.protocolService.hasAccount()) {
prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword))
} else {
return true
}
}
const lastSessionLength = this.getLastSessionLength()
const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength)
? lastSessionLength
: UnprotectedAccessSecondsDuration.OneMinute
prompts.push(
new ChallengePrompt(
ChallengeValidation.ProtectionSessionDuration,
undefined,
undefined,
undefined,
undefined,
chosenSessionLength,
),
)
const response = await this.challengeService.promptForChallengeResponse(new Challenge(prompts, reason, true))
if (response) {
const length = response.values.find(
(value) => value.prompt.validation === ChallengeValidation.ProtectionSessionDuration,
)?.value
if (isNullOrUndefined(length)) {
SNLog.error(Error('No valid protection session length found. Got ' + length))
} else {
await this.setSessionLength(length as UnprotectedAccessSecondsDuration)
}
return true
} else {
return false
}
}
public getSessionExpiryDate(): Date {
const expiresAt = this.storageService.getValue<number>(StorageKey.ProtectionExpirey)
if (expiresAt) {
return new Date(expiresAt)
} else {
return new Date()
}
}
public clearSession(): Promise<void> {
void this.setSessionExpiryDate(new Date())
return this.notifyEvent(ProtectionEvent.UnprotectedSessionExpired)
}
private setSessionExpiryDate(date: Date) {
this.storageService.setValue(StorageKey.ProtectionExpirey, date)
}
private getLastSessionLength(): UnprotectedAccessSecondsDuration | undefined {
return this.storageService.getValue(StorageKey.ProtectionSessionLength)
}
private setSessionLength(length: UnprotectedAccessSecondsDuration): void {
this.storageService.setValue(StorageKey.ProtectionSessionLength, length)
const expiresAt = new Date()
expiresAt.setSeconds(expiresAt.getSeconds() + length)
this.setSessionExpiryDate(expiresAt)
this.updateSessionExpiryTimer(expiresAt)
void this.notifyEvent(ProtectionEvent.UnprotectedSessionBegan)
}
private updateSessionExpiryTimer(expiryDate: Date) {
clearTimeout(this.sessionExpiryTimeout)
const timer: TimerHandler = () => {
void this.clearSession()
}
this.sessionExpiryTimeout = setTimeout(timer, expiryDate.getTime() - Date.now())
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
protections: {
getSessionExpiryDate: this.getSessionExpiryDate(),
getLastSessionLength: this.getLastSessionLength(),
hasProtectionSources: this.hasProtectionSources(),
hasUnprotectedAccessSession: this.hasUnprotectedAccessSession(),
hasBiometricsEnabled: this.hasBiometricsEnabled(),
},
})
}
}

View File

@@ -0,0 +1,2 @@
export * from './ClientInterface'
export * from './ProtectionService'

View File

@@ -0,0 +1,673 @@
import {
AlertService,
AbstractService,
InternalEventBusInterface,
StorageKey,
DiagnosticInfo,
ChallengePrompt,
ChallengeValidation,
ChallengeKeyboardType,
ChallengeReason,
ChallengePromptTitle,
} from '@standardnotes/services'
import { Base64String } from '@standardnotes/sncrypto-common'
import { ClientDisplayableError } from '@standardnotes/responses'
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
import { isNullOrUndefined } from '@standardnotes/utils'
import { JwtSession } from './Sessions/JwtSession'
import {
KeyParamsFromApiResponse,
SNRootKeyParams,
SNRootKey,
EncryptionService,
CreateNewRootKey,
} from '@standardnotes/encryption'
import { SessionStrings, SignInStrings } from '../Api/Messages'
import { RemoteSession, RawStorageValue } from './Sessions/Types'
import { Session } from './Sessions/Session'
import { SessionFromRawStorageValue } from './Sessions/Generator'
import { SessionsClientInterface } from './SessionsClientInterface'
import { ShareToken } from './ShareToken'
import { SNApiService } from '../Api/ApiService'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNWebSocketsService } from '../Api/WebsocketsService'
import { Strings } from '@Lib/Strings'
import { Subscription } from '@standardnotes/security'
import { TokenSession } from './Sessions/TokenSession'
import { UuidString } from '@Lib/Types/UuidString'
import * as Common from '@standardnotes/common'
import * as Messages from '../Api/Messages'
import * as Responses from '@standardnotes/responses'
import { Challenge, ChallengeService } from '../Challenge'
import {
ApiCallError,
ErrorMessage,
HttpErrorResponseBody,
UserApiServiceInterface,
UserRegistrationResponseBody,
} from '@standardnotes/api'
export const MINIMUM_PASSWORD_LENGTH = 8
export const MissingAccountParams = 'missing-params'
type SessionManagerResponse = {
response: Responses.HttpResponse
rootKey?: SNRootKey
keyParams?: Common.AnyKeyParamsContent
}
const cleanedEmailString = (email: string) => {
return email.trim().toLowerCase()
}
export enum SessionEvent {
Restored = 'SessionRestored',
Revoked = 'SessionRevoked',
}
/**
* The session manager is responsible for loading initial user state, and any relevant
* server credentials, such as the session token. It also exposes methods for registering
* for a new account, signing into an existing one, or changing an account password.
*/
export class SNSessionManager extends AbstractService<SessionEvent> implements SessionsClientInterface {
private user?: Responses.User
private isSessionRenewChallengePresented = false
constructor(
private diskStorageService: DiskStorageService,
private apiService: SNApiService,
private userApiService: UserApiServiceInterface,
private alertService: AlertService,
private protocolService: EncryptionService,
private challengeService: ChallengeService,
private webSocketsService: SNWebSocketsService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
apiService.setInvalidSessionObserver((revoked) => {
if (revoked) {
void this.notifyEvent(SessionEvent.Revoked)
} else {
void this.reauthenticateInvalidSession()
}
})
}
override deinit(): void {
;(this.protocolService as unknown) = undefined
;(this.diskStorageService as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.webSocketsService as unknown) = undefined
this.user = undefined
super.deinit()
}
private setUser(user?: Responses.User) {
this.user = user
this.apiService.setUser(user)
}
public initializeFromDisk() {
this.setUser(this.diskStorageService.getValue(StorageKey.User))
if (!this.user) {
const legacyUuidLookup = this.diskStorageService.getValue<string>(StorageKey.LegacyUuid)
if (legacyUuidLookup) {
this.setUser({ uuid: legacyUuidLookup, email: legacyUuidLookup })
}
}
const rawSession = this.diskStorageService.getValue<RawStorageValue>(StorageKey.Session)
if (rawSession) {
const session = SessionFromRawStorageValue(rawSession)
this.setSession(session, false)
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
}
}
private setSession(session: Session, persist = true): void {
this.apiService.setSession(session, persist)
}
public online() {
return !this.offline()
}
public offline() {
return isNullOrUndefined(this.apiService.getSession())
}
public getUser() {
return this.user
}
public getSureUser() {
return this.user as Responses.User
}
public getSession() {
return this.apiService.getSession()
}
public async signOut() {
this.setUser(undefined)
const session = this.apiService.getSession()
if (session && session.canExpire()) {
await this.apiService.signOut()
this.webSocketsService.closeWebSocketConnection()
}
}
public isSignedIn(): boolean {
return this.getUser() != undefined
}
public isSignedIntoFirstPartyServer(): boolean {
return this.isSignedIn() && !this.apiService.isThirdPartyHostUsed()
}
public async reauthenticateInvalidSession(
cancelable = true,
onResponse?: (response: Responses.HttpResponse) => void,
): Promise<void> {
if (this.isSessionRenewChallengePresented) {
return
}
this.isSessionRenewChallengePresented = true
const challenge = new Challenge(
[
new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.EmailInputPlaceholder, false),
new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder),
],
ChallengeReason.Custom,
cancelable,
SessionStrings.EnterEmailAndPassword,
SessionStrings.RecoverSession(this.getUser()?.email),
)
return new Promise((resolve) => {
this.challengeService.addChallengeObserver(challenge, {
onCancel: () => {
this.isSessionRenewChallengePresented = false
},
onComplete: () => {
this.isSessionRenewChallengePresented = false
},
onNonvalidatedSubmit: async (challengeResponse) => {
const email = challengeResponse.values[0].value as string
const password = challengeResponse.values[1].value as string
const currentKeyParams = this.protocolService.getAccountKeyParams()
const signInResult = await this.signIn(
email,
password,
false,
this.diskStorageService.isEphemeralSession(),
currentKeyParams?.version,
)
if (signInResult.response.error) {
this.challengeService.setValidationStatusForChallenge(challenge, challengeResponse!.values[1], false)
onResponse?.(signInResult.response)
} else {
resolve()
this.challengeService.completeChallenge(challenge)
void this.notifyEvent(SessionEvent.Restored)
void this.alertService.alert(SessionStrings.SessionRestored)
}
},
})
void this.challengeService.promptForChallengeResponse(challenge)
})
}
public async getSubscription(): Promise<ClientDisplayableError | Subscription> {
const result = await this.apiService.getSubscription(this.getSureUser().uuid)
if (result.error) {
return ClientDisplayableError.FromError(result.error)
}
const subscription = (result as Responses.GetSubscriptionResponse).data!.subscription!
return subscription
}
public async getAvailableSubscriptions(): Promise<Responses.AvailableSubscriptions | ClientDisplayableError> {
const response = await this.apiService.getAvailableSubscriptions()
if (response.error) {
return ClientDisplayableError.FromError(response.error)
}
return (response as Responses.GetAvailableSubscriptionsResponse).data!
}
private async promptForMfaValue(): Promise<string | undefined> {
const challenge = new Challenge(
[
new ChallengePrompt(
ChallengeValidation.None,
ChallengePromptTitle.Mfa,
SessionStrings.MfaInputPlaceholder,
false,
ChallengeKeyboardType.Numeric,
),
],
ChallengeReason.Custom,
true,
SessionStrings.EnterMfa,
)
const response = await this.challengeService.promptForChallengeResponse(challenge)
if (response) {
this.challengeService.completeChallenge(challenge)
return response.values[0].value as string
}
return undefined
}
async register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody> {
if (password.length < MINIMUM_PASSWORD_LENGTH) {
throw new ApiCallError(
ErrorMessage.InsufficientPasswordMessage.replace('%LENGTH%', MINIMUM_PASSWORD_LENGTH.toString()),
)
}
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable()
if (canceled) {
throw new ApiCallError(ErrorMessage.PasscodeRequired)
}
email = cleanedEmailString(email)
const rootKey = await this.protocolService.createRootKey(email, password, Common.KeyParamsOrigination.Registration)
const serverPassword = rootKey.serverPassword as string
const keyParams = rootKey.keyParams
const registerResponse = await this.userApiService.register(email, serverPassword, keyParams, ephemeral)
if ('error' in registerResponse.data) {
throw new ApiCallError((registerResponse.data as HttpErrorResponseBody).error.message)
}
await this.handleAuthResponse(registerResponse.data, rootKey, wrappingKey)
return registerResponse.data
}
private async retrieveKeyParams(
email: string,
mfaKeyPath?: string,
mfaCode?: string,
): Promise<{
keyParams?: SNRootKeyParams
response: Responses.KeyParamsResponse | Responses.HttpResponse
mfaKeyPath?: string
mfaCode?: string
}> {
const response = await this.apiService.getAccountKeyParams({
email,
mfaKeyPath,
mfaCode,
})
if (response.error || isNullOrUndefined(response.data)) {
if (mfaCode) {
await this.alertService.alert(SignInStrings.IncorrectMfa)
}
if (response.error?.payload?.mfa_key) {
/** Prompt for MFA code and try again */
const inputtedCode = await this.promptForMfaValue()
if (!inputtedCode) {
/** User dismissed window without input */
return {
response: this.apiService.createErrorResponse(
SignInStrings.SignInCanceledMissingMfa,
Responses.StatusCode.CanceledMfa,
),
}
}
return this.retrieveKeyParams(email, response.error.payload.mfa_key, inputtedCode)
} else {
return { response }
}
}
/** Make sure to use client value for identifier/email */
const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, email)
if (!keyParams || !keyParams.version) {
return {
response: this.apiService.createErrorResponse(Messages.API_MESSAGE_FALLBACK_LOGIN_FAIL),
}
}
return { keyParams, response, mfaKeyPath, mfaCode }
}
public async signIn(
email: string,
password: string,
strict = false,
ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion,
): Promise<SessionManagerResponse> {
const result = await this.performSignIn(email, password, strict, ephemeral, minAllowedVersion)
if (
result.response.error &&
result.response.error.status !== Responses.StatusCode.LocalValidationError &&
result.response.error.status !== Responses.StatusCode.CanceledMfa
) {
const cleanedEmail = cleanedEmailString(email)
if (cleanedEmail !== email) {
/**
* Try signing in with trimmed + lowercase version of email
*/
return this.performSignIn(cleanedEmail, password, strict, ephemeral, minAllowedVersion)
} else {
return result
}
} else {
return result
}
}
private async performSignIn(
email: string,
password: string,
strict = false,
ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion,
): Promise<SessionManagerResponse> {
const paramsResult = await this.retrieveKeyParams(email)
if (paramsResult.response.error) {
return {
response: paramsResult.response,
}
}
const keyParams = paramsResult.keyParams!
if (!this.protocolService.supportedVersions().includes(keyParams.version)) {
if (this.protocolService.isVersionNewerThanLibraryVersion(keyParams.version)) {
return {
response: this.apiService.createErrorResponse(Messages.UNSUPPORTED_PROTOCOL_VERSION),
}
} else {
return {
response: this.apiService.createErrorResponse(Messages.EXPIRED_PROTOCOL_VERSION),
}
}
}
if (Common.isProtocolVersionExpired(keyParams.version)) {
/* Cost minimums only apply to now outdated versions (001 and 002) */
const minimum = this.protocolService.costMinimumForVersion(keyParams.version)
if (keyParams.content002.pw_cost < minimum) {
return {
response: this.apiService.createErrorResponse(Messages.INVALID_PASSWORD_COST),
}
}
const expiredMessages = Strings.Confirm.ProtocolVersionExpired(keyParams.version)
const confirmed = await this.alertService.confirm(
expiredMessages.Message,
expiredMessages.Title,
expiredMessages.ConfirmButton,
)
if (!confirmed) {
return {
response: this.apiService.createErrorResponse(Messages.API_MESSAGE_FALLBACK_LOGIN_FAIL),
}
}
}
if (!this.protocolService.platformSupportsKeyDerivation(keyParams)) {
return {
response: this.apiService.createErrorResponse(Messages.UNSUPPORTED_KEY_DERIVATION),
}
}
if (strict) {
minAllowedVersion = this.protocolService.getLatestVersion()
}
if (!isNullOrUndefined(minAllowedVersion)) {
if (!Common.leftVersionGreaterThanOrEqualToRight(keyParams.version, minAllowedVersion)) {
return {
response: this.apiService.createErrorResponse(
Messages.StrictSignInFailed(keyParams.version, minAllowedVersion),
),
}
}
}
const rootKey = await this.protocolService.computeRootKey(password, keyParams)
const signInResponse = await this.bypassChecksAndSignInWithRootKey(email, rootKey, ephemeral)
return {
response: signInResponse,
}
}
public async bypassChecksAndSignInWithRootKey(
email: string,
rootKey: SNRootKey,
ephemeral = false,
): Promise<Responses.SignInResponse | Responses.HttpResponse> {
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable()
if (canceled) {
return this.apiService.createErrorResponse(
SignInStrings.PasscodeRequired,
Responses.StatusCode.LocalValidationError,
)
}
const signInResponse = await this.apiService.signIn({
email,
serverPassword: rootKey.serverPassword!,
ephemeral,
})
if (signInResponse.error || !signInResponse.data) {
return signInResponse
}
const updatedKeyParams = (signInResponse as Responses.SignInResponse).data.key_params
const expandedRootKey = new SNRootKey(
CopyPayloadWithContentOverride(rootKey.payload, {
keyParams: updatedKeyParams || rootKey.keyParams.getPortableValue(),
}),
)
await this.handleSuccessAuthResponse(signInResponse as Responses.SignInResponse, expandedRootKey, wrappingKey)
return signInResponse
}
public async changeCredentials(parameters: {
currentServerPassword: string
newRootKey: SNRootKey
wrappingKey?: SNRootKey
newEmail?: string
}): Promise<SessionManagerResponse> {
const userUuid = this.user!.uuid
const response = await this.apiService.changeCredentials({
userUuid,
currentServerPassword: parameters.currentServerPassword,
newServerPassword: parameters.newRootKey.serverPassword!,
newKeyParams: parameters.newRootKey.keyParams,
newEmail: parameters.newEmail,
})
return this.processChangeCredentialsResponse(
response as Responses.ChangeCredentialsResponse,
parameters.newRootKey,
parameters.wrappingKey,
)
}
public async getSessionsList(): Promise<
(Responses.HttpResponse & { data: RemoteSession[] }) | Responses.HttpResponse
> {
const response = await this.apiService.getSessionsList()
if (response.error || isNullOrUndefined(response.data)) {
return response
}
;(
response as Responses.HttpResponse & {
data: RemoteSession[]
}
).data = (response as Responses.SessionListResponse).data
.map<RemoteSession>((session) => ({
...session,
updated_at: new Date(session.updated_at),
}))
.sort((s1: RemoteSession, s2: RemoteSession) => (s1.updated_at < s2.updated_at ? 1 : -1))
return response
}
public async revokeSession(sessionId: UuidString): Promise<Responses.HttpResponse> {
const response = await this.apiService.deleteSession(sessionId)
return response
}
public async revokeAllOtherSessions(): Promise<void> {
const response = await this.getSessionsList()
if (response.error != undefined || response.data == undefined) {
throw new Error(response.error?.message ?? Messages.API_MESSAGE_GENERIC_SYNC_FAIL)
}
const sessions = response.data as RemoteSession[]
const otherSessions = sessions.filter((session) => !session.current)
await Promise.all(otherSessions.map((session) => this.revokeSession(session.uuid)))
}
private async processChangeCredentialsResponse(
response: Responses.ChangeCredentialsResponse,
newRootKey: SNRootKey,
wrappingKey?: SNRootKey,
): Promise<SessionManagerResponse> {
if (!response.error && response.data) {
await this.handleSuccessAuthResponse(response as Responses.ChangeCredentialsResponse, newRootKey, wrappingKey)
}
return {
response: response,
keyParams: (response as Responses.ChangeCredentialsResponse).data?.key_params,
}
}
public async createDemoShareToken(): Promise<Base64String | ClientDisplayableError> {
const session = this.getSession()
if (!session) {
return new ClientDisplayableError('Cannot generate share token without active session')
}
if (!(session instanceof TokenSession)) {
return new ClientDisplayableError('Cannot generate share token with non-token session')
}
const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams
const payload: ShareToken = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
accessExpiration: session.accessExpiration,
refreshExpiration: session.refreshExpiration,
readonlyAccess: true,
masterKey: this.protocolService.getRootKey()?.masterKey as string,
keyParams: keyParams.content,
user: this.getSureUser(),
host: this.apiService.getHost(),
}
return this.protocolService.crypto.base64Encode(JSON.stringify(payload))
}
private decodeDemoShareToken(token: Base64String): ShareToken {
const jsonString = this.protocolService.crypto.base64Decode(token)
return JSON.parse(jsonString)
}
public async populateSessionFromDemoShareToken(token: Base64String): Promise<void> {
const sharePayload = this.decodeDemoShareToken(token)
const rootKey = CreateNewRootKey({
masterKey: sharePayload.masterKey,
keyParams: sharePayload.keyParams,
version: sharePayload.keyParams.version,
})
const user = sharePayload.user
const session = new TokenSession(
sharePayload.accessToken,
sharePayload.accessExpiration,
sharePayload.refreshToken,
sharePayload.refreshExpiration,
sharePayload.readonlyAccess,
)
await this.populateSession(rootKey, user, session, sharePayload.host)
}
private async populateSession(
rootKey: SNRootKey,
user: Responses.User,
session: Session,
host: string,
wrappingKey?: SNRootKey,
) {
await this.protocolService.setRootKey(rootKey, wrappingKey)
this.setUser(user)
this.diskStorageService.setValue(StorageKey.User, user)
void this.apiService.setHost(host)
await this.setSession(session)
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
}
private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) {
const session = new TokenSession(
body.session.access_token,
body.session.access_expiration,
body.session.refresh_token,
body.session.refresh_expiration,
body.session.readonly_access,
)
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
}
/**
* @deprecated use handleAuthResponse instead
*/
private async handleSuccessAuthResponse(
response: Responses.SignInResponse | Responses.ChangeCredentialsResponse,
rootKey: SNRootKey,
wrappingKey?: SNRootKey,
) {
const { data } = response
const user = data.user as Responses.User
const isLegacyJwtResponse = data.token != undefined
if (isLegacyJwtResponse) {
const session = new JwtSession(data.token as string)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
} else if (data.session) {
const session = TokenSession.FromApiResponse(response)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
}
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
session: {
isSessionRenewChallengePresented: this.isSessionRenewChallengePresented,
online: this.online(),
offline: this.offline(),
isSignedIn: this.isSignedIn(),
isSignedIntoFirstPartyServer: this.isSignedIntoFirstPartyServer(),
},
})
}
}

View File

@@ -0,0 +1,18 @@
import { JwtSession } from './JwtSession'
import { TokenSession } from './TokenSession'
import { RawSessionPayload, RawStorageValue } from './Types'
export function SessionFromRawStorageValue(raw: RawStorageValue): JwtSession | TokenSession {
if ('jwt' in raw) {
return new JwtSession(raw.jwt as string)
} else {
const rawSession = raw as RawSessionPayload
return new TokenSession(
rawSession.accessToken,
rawSession.accessExpiration,
rawSession.refreshToken,
rawSession.refreshExpiration,
rawSession.readonlyAccess,
)
}
}

View File

@@ -0,0 +1,20 @@
import { Session } from './Session'
/** Legacy, for protocol versions <= 003 */
export class JwtSession extends Session {
public jwt: string
constructor(jwt: string) {
super()
this.jwt = jwt
}
public get authorizationValue(): string {
return this.jwt
}
public canExpire(): false {
return false
}
}

View File

@@ -0,0 +1,6 @@
export abstract class Session {
public abstract canExpire(): boolean
/** Return the token that should be included in the header of authorized network requests */
public abstract get authorizationValue(): string
}

View File

@@ -0,0 +1,46 @@
import { SessionBody, SessionRenewalResponse } from '@standardnotes/responses'
import { Session } from './Session'
/** For protocol versions >= 004 */
export class TokenSession extends Session {
static FromApiResponse(response: SessionRenewalResponse) {
const body = response.data.session as SessionBody
const accessToken: string = body.access_token
const refreshToken: string = body.refresh_token
const accessExpiration: number = body.access_expiration
const refreshExpiration: number = body.refresh_expiration
const readonlyAccess: boolean = body.readonly_access
return new TokenSession(accessToken, accessExpiration, refreshToken, refreshExpiration, readonlyAccess)
}
constructor(
public accessToken: string,
public accessExpiration: number,
public refreshToken: string,
public refreshExpiration: number,
private readonlyAccess: boolean,
) {
super()
}
isReadOnly() {
return this.readonlyAccess
}
private getExpireAt() {
return this.accessExpiration || 0
}
public get authorizationValue() {
return this.accessToken
}
public canExpire() {
return true
}
public isExpired() {
return this.getExpireAt() < Date.now()
}
}

View File

@@ -0,0 +1,22 @@
import { Uuid } from '@standardnotes/common'
export type RawJwtPayload = {
jwt?: string
}
export type RawSessionPayload = {
accessToken: string
refreshToken: string
accessExpiration: number
refreshExpiration: number
readonlyAccess: boolean
}
export type RawStorageValue = RawJwtPayload | RawSessionPayload
export type RemoteSession = {
uuid: Uuid
updated_at: Date
device_info: string
current: boolean
}

View File

@@ -0,0 +1,5 @@
export * from './Generator'
export * from './JwtSession'
export * from './Session'
export * from './TokenSession'
export * from './Types'

View File

@@ -0,0 +1,8 @@
import { ClientDisplayableError } from '@standardnotes/responses'
import { Base64String } from '@standardnotes/sncrypto-common'
export interface SessionsClientInterface {
createDemoShareToken(): Promise<Base64String | ClientDisplayableError>
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
}

View File

@@ -0,0 +1,10 @@
import { User } from '@standardnotes/responses'
import { AnyKeyParamsContent } from '@standardnotes/common'
import { RawSessionPayload } from './Sessions/Types'
export type ShareToken = RawSessionPayload & {
masterKey: string
keyParams: AnyKeyParamsContent
user: User
host: string
}

View File

@@ -0,0 +1,4 @@
export * from './SessionManager'
export * from './Sessions'
export * from './SessionsClientInterface'
export * from './ShareToken'

View File

@@ -0,0 +1,81 @@
import { SNApiService } from '../Api/ApiService'
import { SettingsGateway } from './SettingsGateway'
import { SNSessionManager } from '../Session/SessionManager'
import {
CloudProvider,
EmailBackupFrequency,
SettingName,
SensitiveSettingName,
SubscriptionSettingName,
} from '@standardnotes/settings'
import { ExtensionsServerURL } from '@Lib/Hosts'
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services'
import { SettingsClientInterface } from './SettingsClientInterface'
export class SNSettingsService extends AbstractService implements SettingsClientInterface {
private provider!: SettingsGateway
private frequencyOptionsLabels = {
[EmailBackupFrequency.Disabled]: 'No email backups',
[EmailBackupFrequency.Daily]: 'Daily',
[EmailBackupFrequency.Weekly]: 'Weekly',
}
private cloudProviderIntegrationUrlEndpoints = {
[CloudProvider.Dropbox]: 'dropbox',
[CloudProvider.Google]: 'gdrive',
[CloudProvider.OneDrive]: 'onedrive',
}
constructor(
private readonly sessionManager: SNSessionManager,
private readonly apiService: SNApiService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
initializeFromDisk(): void {
this.provider = new SettingsGateway(this.apiService, this.sessionManager)
}
async listSettings() {
return this.provider.listSettings()
}
async getSetting(name: SettingName) {
return this.provider.getSetting(name)
}
async getSubscriptionSetting(name: SubscriptionSettingName) {
return this.provider.getSubscriptionSetting(name)
}
async updateSetting(name: SettingName, payload: string, sensitive = false) {
return this.provider.updateSetting(name, payload, sensitive)
}
async getDoesSensitiveSettingExist(name: SensitiveSettingName) {
return this.provider.getDoesSensitiveSettingExist(name)
}
async deleteSetting(name: SettingName) {
return this.provider.deleteSetting(name)
}
getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string {
return this.frequencyOptionsLabels[frequency]
}
getCloudProviderIntegrationUrl(cloudProviderName: CloudProvider, isDevEnvironment: boolean): string {
const { Dev, Prod } = ExtensionsServerURL
const extServerUrl = isDevEnvironment ? Dev : Prod
return `${extServerUrl}/${this.cloudProviderIntegrationUrlEndpoints[cloudProviderName]}?redirect_url=${extServerUrl}/components/cloudlink?`
}
override deinit(): void {
this.provider?.deinit()
;(this.provider as unknown) = undefined
;(this.sessionManager as unknown) = undefined
;(this.apiService as unknown) = undefined
}
}

View File

@@ -0,0 +1,16 @@
import { SettingName, SensitiveSettingName, EmailBackupFrequency } from '@standardnotes/settings'
import { SettingsList } from './SettingsList'
export interface SettingsClientInterface {
listSettings(): Promise<SettingsList>
getSetting(name: SettingName): Promise<string | undefined>
getDoesSensitiveSettingExist(name: SensitiveSettingName): Promise<boolean>
updateSetting(name: SettingName, payload: string, sensitive?: boolean): Promise<void>
deleteSetting(name: SettingName): Promise<void>
getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string
}

View File

@@ -0,0 +1,110 @@
import { SettingsList } from './SettingsList'
import { SettingName, SensitiveSettingName, SubscriptionSettingName } from '@standardnotes/settings'
import * as messages from '../Api/Messages'
import { StatusCode, User } from '@standardnotes/responses'
import { SettingsServerInterface } from './SettingsServerInterface'
/**
* SettingsGateway coordinates communication with the API service
* wrapping the userUuid provision for simpler consumption
*/
export class SettingsGateway {
constructor(
private readonly settingsApi: SettingsServerInterface,
private readonly userProvider: { getUser: () => User | undefined },
) {}
isReadyForModification(): boolean {
return this.getUser() != undefined
}
private getUser() {
return this.userProvider.getUser()
}
private get userUuid() {
const user = this.getUser()
if (user == undefined || user.uuid == undefined) {
throw new Error(messages.API_MESSAGE_INVALID_SESSION)
}
return user.uuid
}
async listSettings() {
const { error, data } = await this.settingsApi.listSettings(this.userUuid)
if (error != undefined) {
throw new Error(error.message)
}
if (data == undefined || data.settings == undefined) {
return new SettingsList([])
}
const settings: SettingsList = new SettingsList(data.settings)
return settings
}
async getSetting(name: SettingName): Promise<string | undefined> {
const response = await this.settingsApi.getSetting(this.userUuid, name)
// Backend responds with 400 when setting doesn't exist
if (response.status === StatusCode.HttpBadRequest) {
return undefined
}
if (response.error != undefined) {
throw new Error(response.error.message)
}
return response?.data?.setting?.value ?? undefined
}
async getSubscriptionSetting(name: SubscriptionSettingName): Promise<string | undefined> {
const response = await this.settingsApi.getSubscriptionSetting(this.userUuid, name)
if (response.status === StatusCode.HttpBadRequest) {
return undefined
}
if (response.error != undefined) {
throw new Error(response.error.message)
}
return response?.data?.setting?.value ?? undefined
}
async getDoesSensitiveSettingExist(name: SensitiveSettingName): Promise<boolean> {
const response = await this.settingsApi.getSetting(this.userUuid, name)
// Backend responds with 400 when setting doesn't exist
if (response.status === StatusCode.HttpBadRequest) {
return false
}
if (response.error != undefined) {
throw new Error(response.error.message)
}
return response.data?.success ?? false
}
async updateSetting(name: SettingName, payload: string, sensitive: boolean): Promise<void> {
const { error } = await this.settingsApi.updateSetting(this.userUuid, name, payload, sensitive)
if (error != undefined) {
throw new Error(error.message)
}
}
async deleteSetting(name: SettingName): Promise<void> {
const { error } = await this.settingsApi.deleteSetting(this.userUuid, name)
if (error != undefined) {
throw new Error(error.message)
}
}
deinit() {
;(this.settingsApi as unknown) = undefined
;(this.userProvider as unknown) = undefined
}
}

View File

@@ -0,0 +1,41 @@
import { SettingData } from '@standardnotes/responses'
import {
OneDriveBackupFrequency,
MuteSignInEmailsOption,
MuteFailedCloudBackupsEmailsOption,
MuteFailedBackupsEmailsOption,
CloudProvider,
DropboxBackupFrequency,
EmailBackupFrequency,
GoogleDriveBackupFrequency,
ListedAuthorSecretsData,
LogSessionUserAgentOption,
SettingName,
} from '@standardnotes/settings'
type SettingType =
| CloudProvider
| DropboxBackupFrequency
| EmailBackupFrequency
| GoogleDriveBackupFrequency
| ListedAuthorSecretsData
| LogSessionUserAgentOption
| MuteFailedBackupsEmailsOption
| MuteFailedCloudBackupsEmailsOption
| MuteSignInEmailsOption
| OneDriveBackupFrequency
export class SettingsList {
private map: Partial<Record<SettingName, SettingData>> = {}
constructor(settings: SettingData[]) {
for (const setting of settings) {
this.map[setting.name as SettingName] = setting
}
}
getSettingValue<T = SettingType, D = SettingType>(setting: SettingName, defaultValue: D): T {
const settingData = this.map[setting]
return (settingData?.value as unknown as T) || (defaultValue as unknown as T)
}
}

View File

@@ -0,0 +1,24 @@
import {
DeleteSettingResponse,
GetSettingResponse,
ListSettingsResponse,
UpdateSettingResponse,
} from '@standardnotes/responses'
import { UuidString } from '@Lib/Types/UuidString'
export interface SettingsServerInterface {
listSettings(userUuid: UuidString): Promise<ListSettingsResponse>
updateSetting(
userUuid: UuidString,
settingName: string,
settingValue: string,
sensitive: boolean,
): Promise<UpdateSettingResponse>
getSetting(userUuid: UuidString, settingName: string): Promise<GetSettingResponse>
getSubscriptionSetting(userUuid: UuidString, settingName: string): Promise<GetSettingResponse>
deleteSetting(userUuid: UuidString, settingName: string): Promise<DeleteSettingResponse>
}

View File

@@ -0,0 +1 @@
export * from './SNSettingsService'

View File

@@ -0,0 +1,231 @@
import { PayloadManager } from './../Payloads/PayloadManager'
import { ContentType } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import {
DecryptedItemInterface,
DecryptedPayload,
SingletonStrategy,
ItemContent,
PredicateInterface,
PayloadEmitSource,
PayloadTimestampDefaults,
getIncrementedDirtyIndex,
} from '@standardnotes/models'
import { arrayByRemovingFromIndex, extendArray, UuidGenerator } from '@standardnotes/utils'
import { SNSyncService } from '../Sync/SyncService'
import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standardnotes/services'
/**
* The singleton manager allow consumers to ensure that only 1 item exists of a certain
* predicate. For example, consumers may want to ensure that only one item of contentType
* UserPreferences exist. The singleton manager allows consumers to do this via 2 methods:
* 1. Consumers may use `findOrCreateContentTypeSingleton` to retrieve an item if it exists, or create
* it otherwise. While this method may serve most cases, it does not allow the consumer
* to subscribe to changes, such as if after this method is called, a UserPreferences object
* is downloaded from a remote source.
* 2. Items can override isSingleton, singletonPredicate, and strategyWhenConflictingWithItem (optional)
* to automatically gain singleton resolution.
*/
export class SNSingletonManager extends AbstractService {
private resolveQueue: DecryptedItemInterface[] = []
private removeItemObserver!: () => void
private removeSyncObserver!: () => void
constructor(
private itemManager: ItemManager,
private payloadManager: PayloadManager,
private syncService: SNSyncService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.addObservers()
}
public override deinit(): void {
;(this.syncService as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.payloadManager as unknown) = undefined
this.resolveQueue.length = 0
this.removeItemObserver()
;(this.removeItemObserver as unknown) = undefined
this.removeSyncObserver()
;(this.removeSyncObserver as unknown) = undefined
super.deinit()
}
private popResolveQueue() {
const queue = this.resolveQueue.slice()
this.resolveQueue = []
return queue
}
/**
* We only want to resolve singletons for items that are newly created (because this
* is when items proliferate). However, we don't want to resolve immediately on creation,
* but instead wait for the next full sync to complete. This is so that when you download
* a singleton and create the object, but the items key for the item has not yet been
* downloaded, the singleton will be errorDecrypting, and would be mishandled in the
* overall singleton logic. By waiting for a full sync to complete, we can be sure that
* all items keys have been downloaded.
*/
private addObservers() {
this.removeItemObserver = this.itemManager.addObserver(ContentType.Any, ({ inserted, unerrored }) => {
if (unerrored.length > 0) {
this.resolveQueue = this.resolveQueue.concat(unerrored)
}
if (inserted.length > 0) {
this.resolveQueue = this.resolveQueue.concat(inserted)
}
})
this.removeSyncObserver = this.syncService.addEventObserver(async (eventName) => {
if (
eventName === SyncEvent.DownloadFirstSyncCompleted ||
eventName === SyncEvent.SyncCompletedWithAllItemsUploaded
) {
await this.resolveSingletonsForItems(this.popResolveQueue(), eventName)
}
})
}
private validItemsMatchingPredicate<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
) {
return this.itemManager.itemsMatchingPredicate(contentType, predicate)
}
private async resolveSingletonsForItems(items: DecryptedItemInterface[], eventSource: SyncEvent) {
if (items.length === 0) {
return
}
const handled: DecryptedItemInterface[] = []
for (const item of items) {
if (handled.includes(item) || !item.isSingleton) {
continue
}
const matchingItems = this.validItemsMatchingPredicate<DecryptedItemInterface>(
item.content_type,
item.singletonPredicate(),
)
extendArray(handled, matchingItems || [])
if (!matchingItems || matchingItems.length <= 1) {
continue
}
await this.handleStrategy(matchingItems, item.singletonStrategy)
}
/**
* Only sync if event source is SyncCompletedWithAllItemsUploaded.
* If it is on DownloadFirstSyncCompleted, we don't need to sync,
* as a sync request will automatically be made as part of the second phase
* of a download-first request.
*/
if (handled.length > 0 && eventSource === SyncEvent.SyncCompletedWithAllItemsUploaded) {
await this.syncService?.sync()
}
}
private async handleStrategy(items: DecryptedItemInterface[], strategy: SingletonStrategy) {
if (strategy !== SingletonStrategy.KeepEarliest) {
throw 'Unhandled singleton strategy'
}
const earliestFirst = items.sort((a, b) => {
/** -1: a comes first, 1: b comes first */
return a.created_at < b.created_at ? -1 : 1
})
const deleteItems = arrayByRemovingFromIndex(earliestFirst, 0)
await this.itemManager.setItemsToBeDeleted(deleteItems)
}
public findSingleton<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
): T | undefined {
const matchingItems = this.validItemsMatchingPredicate(contentType, predicate)
if (matchingItems.length > 0) {
return matchingItems[0] as T
}
return undefined
}
public async findOrCreateContentTypeSingleton<
C extends ItemContent = ItemContent,
T extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(contentType: ContentType, createContent: ItemContent): Promise<T> {
const existingItems = this.itemManager.getItems<T>(contentType)
if (existingItems.length > 0) {
return existingItems[0]
}
/** Item not found, safe to create after full sync has completed */
if (!this.syncService.getLastSyncDate()) {
/**
* Add a temporary observer in case of long-running sync request, where
* the item we're looking for ends up resolving early or in the middle.
*/
let matchingItem: DecryptedItemInterface | undefined
const removeObserver = this.itemManager.addObserver(contentType, ({ inserted }) => {
if (inserted.length > 0) {
const matchingItems = inserted.filter((i) => i.content_type === contentType)
if (matchingItems.length > 0) {
matchingItem = matchingItems[0]
}
}
})
await this.syncService.sync()
removeObserver()
if (matchingItem) {
return matchingItem as T
}
/** Check again */
const refreshedItems = this.itemManager.getItems<T>(contentType)
if (refreshedItems.length > 0) {
return refreshedItems[0] as T
}
}
/** Delete any items that are errored */
const errorDecrypting = this.payloadManager.erroredPayloadsForContentType(contentType)
if (errorDecrypting.length) {
await this.payloadManager.deleteErroredPayloads(errorDecrypting)
}
/** Safe to create */
const dirtyPayload = new DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content_type: contentType,
content: createContent,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
...PayloadTimestampDefaults(),
})
const item = await this.itemManager.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted)
void this.syncService.sync()
return item as T
}
}

View File

@@ -0,0 +1,482 @@
import { ContentType, Uuid } from '@standardnotes/common'
import { Copy, extendArray, UuidGenerator } from '@standardnotes/utils'
import { SNLog } from '../../Log'
import { isErrorDecryptingParameters, SNRootKey } from '@standardnotes/encryption'
import * as Encryption from '@standardnotes/encryption'
import * as Services from '@standardnotes/services'
import { DiagnosticInfo, Environment } from '@standardnotes/services'
import {
CreateDecryptedLocalStorageContextPayload,
CreateDeletedLocalStorageContextPayload,
CreateEncryptedLocalStorageContextPayload,
CreatePayloadSplitWithDiscardables,
DecryptedPayload,
EncryptedPayload,
FullyFormedPayloadInterface,
isEncryptedLocalStoragePayload,
ItemContent,
DecryptedPayloadInterface,
DeletedPayloadInterface,
PayloadTimestampDefaults,
LocalStorageEncryptedContextualPayload,
} from '@standardnotes/models'
/**
* The storage service is responsible for persistence of both simple key-values, and payload
* storage. It does so by relying on deviceInterface to save and retrieve raw values and payloads.
* For simple key/values, items are grouped together in an in-memory hash, and persisted to disk
* as a single object (encrypted, when possible). It handles persisting payloads in the local
* database by encrypting the payloads when possible.
* The storage service also exposes methods that allow the application to initially
* decrypt the persisted key/values, and also a method to determine whether a particular
* key can decrypt wrapped storage.
*/
export class DiskStorageService extends Services.AbstractService implements Services.StorageServiceInterface {
private encryptionProvider!: Encryption.EncryptionProvider
private storagePersistable = false
private persistencePolicy!: Services.StoragePersistencePolicies
private encryptionPolicy!: Services.StorageEncryptionPolicy
private needsPersist = false
private currentPersistPromise?: Promise<Services.StorageValuesObject>
private values!: Services.StorageValuesObject
constructor(
private deviceInterface: Services.DeviceInterface,
private identifier: string,
private environment: Environment,
protected override internalEventBus: Services.InternalEventBusInterface,
) {
super(internalEventBus)
void this.setPersistencePolicy(Services.StoragePersistencePolicies.Default)
void this.setEncryptionPolicy(Services.StorageEncryptionPolicy.Default, false)
}
public provideEncryptionProvider(provider: Encryption.EncryptionProvider): void {
this.encryptionProvider = provider
}
public override deinit() {
;(this.deviceInterface as unknown) = undefined
;(this.encryptionProvider as unknown) = undefined
this.storagePersistable = false
super.deinit()
}
override async handleApplicationStage(stage: Services.ApplicationStage) {
await super.handleApplicationStage(stage)
if (stage === Services.ApplicationStage.Launched_10) {
this.storagePersistable = true
if (this.needsPersist) {
void this.persistValuesToDisk()
}
} else if (stage === Services.ApplicationStage.StorageDecrypted_09) {
const persistedPolicy = await this.getValue(Services.StorageKey.StorageEncryptionPolicy)
if (persistedPolicy) {
void this.setEncryptionPolicy(persistedPolicy as Services.StorageEncryptionPolicy, false)
}
}
}
public async setPersistencePolicy(persistencePolicy: Services.StoragePersistencePolicies) {
this.persistencePolicy = persistencePolicy
if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) {
await this.deviceInterface.removeAllRawStorageValues()
await this.clearAllPayloads()
}
}
public setEncryptionPolicy(encryptionPolicy: Services.StorageEncryptionPolicy, persist = true): void {
if (encryptionPolicy === Services.StorageEncryptionPolicy.Disabled && this.environment !== Environment.Mobile) {
throw Error('Disabling storage encryption is only available on mobile.')
}
this.encryptionPolicy = encryptionPolicy
if (persist) {
this.setValue(Services.StorageKey.StorageEncryptionPolicy, encryptionPolicy)
}
}
public isEphemeralSession() {
return this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral
}
public async initializeFromDisk() {
const value = await this.deviceInterface.getRawStorageValue(this.getPersistenceKey())
const values = value ? JSON.parse(value as string) : undefined
this.setInitialValues(values)
}
/**
* Called by platforms with the value they load from disk,
* after they handle initializeFromDisk
*/
private setInitialValues(values?: Services.StorageValuesObject) {
const sureValues = values || this.defaultValuesObject()
if (!sureValues[Services.ValueModesKeys.Unwrapped]) {
sureValues[Services.ValueModesKeys.Unwrapped] = {}
}
this.values = sureValues
}
public isStorageWrapped(): boolean {
const wrappedValue = this.values[Services.ValueModesKeys.Wrapped]
return wrappedValue != undefined && isEncryptedLocalStoragePayload(wrappedValue)
}
public async canDecryptWithKey(key: SNRootKey): Promise<boolean> {
const wrappedValue = this.values[Services.ValueModesKeys.Wrapped]
if (!isEncryptedLocalStoragePayload(wrappedValue)) {
throw Error('Attempting to decrypt non decrypted storage value')
}
const decryptedPayload = await this.decryptWrappedValue(wrappedValue, key)
return !isErrorDecryptingParameters(decryptedPayload)
}
private async decryptWrappedValue(wrappedValue: LocalStorageEncryptedContextualPayload, key?: SNRootKey) {
/**
* The read content type doesn't matter, so long as we know it responds
* to content type. This allows a more seamless transition when both web
* and mobile used different content types for encrypted storage.
*/
if (!wrappedValue?.content_type) {
throw Error('Attempting to decrypt nonexistent wrapped value')
}
const payload = new EncryptedPayload({
...wrappedValue,
...PayloadTimestampDefaults(),
content_type: ContentType.EncryptedStorage,
})
const split: Encryption.KeyedDecryptionSplit = key
? {
usesRootKey: {
items: [payload],
key: key,
},
}
: {
usesRootKeyWithKeyLookup: {
items: [payload],
},
}
const decryptedPayload = await this.encryptionProvider.decryptSplitSingle(split)
return decryptedPayload
}
public async decryptStorage() {
const wrappedValue = this.values[Services.ValueModesKeys.Wrapped]
if (!isEncryptedLocalStoragePayload(wrappedValue)) {
throw Error('Attempting to decrypt already decrypted storage')
}
const decryptedPayload = await this.decryptWrappedValue(wrappedValue)
if (isErrorDecryptingParameters(decryptedPayload)) {
throw SNLog.error(Error('Unable to decrypt storage.'))
}
this.values[Services.ValueModesKeys.Unwrapped] = Copy(decryptedPayload.content)
}
/** @todo This function should be debounced. */
private async persistValuesToDisk() {
if (!this.storagePersistable) {
this.needsPersist = true
return
}
if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) {
return
}
await this.currentPersistPromise
this.needsPersist = false
const values = await this.immediatelyPersistValuesToDisk()
/** Save the persisted value so we have access to it in memory (for unit tests afawk) */
this.values[Services.ValueModesKeys.Wrapped] = values[Services.ValueModesKeys.Wrapped]
}
public async awaitPersist(): Promise<void> {
await this.currentPersistPromise
}
private async immediatelyPersistValuesToDisk(): Promise<Services.StorageValuesObject> {
this.currentPersistPromise = this.executeCriticalFunction(async () => {
const values = await this.generatePersistableValues()
const persistencePolicySuddenlyChanged = this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral
if (persistencePolicySuddenlyChanged) {
return values
}
await this.deviceInterface?.setRawStorageValue(this.getPersistenceKey(), JSON.stringify(values))
return values
})
return this.currentPersistPromise
}
/**
* Generates a payload that can be persisted to disk,
* either as a plain object, or an encrypted item.
*/
private async generatePersistableValues() {
const rawContent = Copy(this.values) as Partial<Services.StorageValuesObject>
const valuesToWrap = rawContent[Services.ValueModesKeys.Unwrapped]
rawContent[Services.ValueModesKeys.Unwrapped] = undefined
const payload = new DecryptedPayload({
uuid: UuidGenerator.GenerateUuid(),
content: valuesToWrap as unknown as ItemContent,
content_type: ContentType.EncryptedStorage,
...PayloadTimestampDefaults(),
})
if (this.encryptionProvider.hasRootKeyEncryptionSource()) {
const split: Encryption.KeyedEncryptionSplit = {
usesRootKeyWithKeyLookup: {
items: [payload],
},
}
const encryptedPayload = await this.encryptionProvider.encryptSplitSingle(split)
rawContent[Services.ValueModesKeys.Wrapped] = CreateEncryptedLocalStorageContextPayload(encryptedPayload)
} else {
rawContent[Services.ValueModesKeys.Wrapped] = CreateDecryptedLocalStorageContextPayload(payload)
}
return rawContent as Services.StorageValuesObject
}
public setValue(key: string, value: unknown, mode = Services.StorageValueModes.Default): void {
this.setValueWithNoPersist(key, value, mode)
void this.persistValuesToDisk()
}
public async setValueAndAwaitPersist(
key: string,
value: unknown,
mode = Services.StorageValueModes.Default,
): Promise<void> {
this.setValueWithNoPersist(key, value, mode)
await this.persistValuesToDisk()
}
private setValueWithNoPersist(key: string, value: unknown, mode = Services.StorageValueModes.Default): void {
if (!this.values) {
throw Error(`Attempting to set storage key ${key} before loading local storage.`)
}
const domainKey = this.domainKeyForMode(mode)
const domainStorage = this.values[domainKey]
domainStorage[key] = value
}
public getValue<T>(key: string, mode = Services.StorageValueModes.Default, defaultValue?: T): T {
if (!this.values) {
throw Error(`Attempting to get storage key ${key} before loading local storage.`)
}
if (!this.values[this.domainKeyForMode(mode)]) {
throw Error(`Storage domain mode not available ${mode} for key ${key}`)
}
const value = this.values[this.domainKeyForMode(mode)][key]
return value != undefined ? (value as T) : (defaultValue as T)
}
public async removeValue(key: string, mode = Services.StorageValueModes.Default): Promise<void> {
if (!this.values) {
throw Error(`Attempting to remove storage key ${key} before loading local storage.`)
}
const domain = this.values[this.domainKeyForMode(mode)]
if (domain?.[key]) {
delete domain[key]
return this.persistValuesToDisk()
}
}
public getStorageEncryptionPolicy() {
return this.encryptionPolicy
}
/**
* Default persistence key. Platforms can override as needed.
*/
private getPersistenceKey() {
return Services.namespacedKey(this.identifier, Services.RawStorageKey.StorageObject)
}
private defaultValuesObject(
wrapped?: Services.WrappedStorageValue,
unwrapped?: Services.ValuesObjectRecord,
nonwrapped?: Services.ValuesObjectRecord,
) {
return DiskStorageService.DefaultValuesObject(wrapped, unwrapped, nonwrapped)
}
public static DefaultValuesObject(
wrapped: Services.WrappedStorageValue = {} as Services.WrappedStorageValue,
unwrapped: Services.ValuesObjectRecord = {},
nonwrapped: Services.ValuesObjectRecord = {},
) {
return {
[Services.ValueModesKeys.Wrapped]: wrapped,
[Services.ValueModesKeys.Unwrapped]: unwrapped,
[Services.ValueModesKeys.Nonwrapped]: nonwrapped,
} as Services.StorageValuesObject
}
private domainKeyForMode(mode: Services.StorageValueModes) {
if (mode === Services.StorageValueModes.Default) {
return Services.ValueModesKeys.Unwrapped
} else if (mode === Services.StorageValueModes.Nonwrapped) {
return Services.ValueModesKeys.Nonwrapped
} else {
throw Error('Invalid mode')
}
}
/**
* Clears simple values from storage only. Does not affect payloads.
*/
async clearValues() {
this.setInitialValues()
await this.immediatelyPersistValuesToDisk()
}
public async getAllRawPayloads() {
return this.deviceInterface.getAllRawDatabasePayloads(this.identifier)
}
public async savePayload(payload: FullyFormedPayloadInterface): Promise<void> {
return this.savePayloads([payload])
}
public async savePayloads(payloads: FullyFormedPayloadInterface[]): Promise<void> {
if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) {
return
}
const { encrypted, decrypted, deleted, discardable } = CreatePayloadSplitWithDiscardables(payloads)
const encryptionEnabled = this.encryptionPolicy === Services.StorageEncryptionPolicy.Default
const rootKeyEncryptionAvailable = this.encryptionProvider.hasRootKeyEncryptionSource()
const encryptable: DecryptedPayloadInterface[] = []
const unencryptable: DecryptedPayloadInterface[] = []
if (encryptionEnabled) {
const split = Encryption.SplitPayloadsByEncryptionType(decrypted)
if (split.itemsKeyEncryption) {
extendArray(encryptable, split.itemsKeyEncryption)
}
if (split.rootKeyEncryption) {
if (!rootKeyEncryptionAvailable) {
extendArray(unencryptable, split.rootKeyEncryption)
} else {
extendArray(encryptable, split.rootKeyEncryption)
}
}
} else {
extendArray(unencryptable, encryptable)
extendArray(unencryptable, decrypted)
}
await this.deletePayloads(discardable)
const split = Encryption.SplitPayloadsByEncryptionType(encryptable)
const keyLookupSplit = Encryption.CreateEncryptionSplitWithKeyLookup(split)
const encryptedResults = await this.encryptionProvider.encryptSplit(keyLookupSplit)
const exportedEncrypted = [...encrypted, ...encryptedResults].map(CreateEncryptedLocalStorageContextPayload)
const exportedDecrypted = unencryptable.map(CreateDecryptedLocalStorageContextPayload)
const exportedDeleted = deleted.map(CreateDeletedLocalStorageContextPayload)
return this.executeCriticalFunction(async () => {
return this.deviceInterface?.saveRawDatabasePayloads(
[...exportedEncrypted, ...exportedDecrypted, ...exportedDeleted],
this.identifier,
)
})
}
public async deletePayloads(payloads: DeletedPayloadInterface[]) {
await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid)))
}
public async forceDeletePayloads(payloads: FullyFormedPayloadInterface[]) {
await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid)))
}
public async deletePayloadWithId(uuid: Uuid) {
return this.executeCriticalFunction(async () => {
return this.deviceInterface.removeRawDatabasePayloadWithId(uuid, this.identifier)
})
}
public async clearAllPayloads() {
return this.executeCriticalFunction(async () => {
return this.deviceInterface.removeAllRawDatabasePayloads(this.identifier)
})
}
public clearAllData(): Promise<void> {
return this.executeCriticalFunction(async () => {
await this.clearValues()
await this.clearAllPayloads()
await this.deviceInterface.removeRawStorageValue(
Services.namespacedKey(this.identifier, Services.RawStorageKey.SnjsVersion),
)
await this.deviceInterface.removeRawStorageValue(this.getPersistenceKey())
})
}
override async getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return {
storage: {
storagePersistable: this.storagePersistable,
persistencePolicy: Services.StoragePersistencePolicies[this.persistencePolicy],
encryptionPolicy: Services.StorageEncryptionPolicy[this.encryptionPolicy],
needsPersist: this.needsPersist,
currentPersistPromise: this.currentPersistPromise != undefined,
isStorageWrapped: this.isStorageWrapped(),
allRawPayloadsCount: (await this.getAllRawPayloads()).length,
databaseKeys: await this.deviceInterface.getDatabaseKeys(),
},
}
}
}

View File

@@ -0,0 +1,110 @@
import { ServerSyncPushContextualPayload } from '@standardnotes/models'
import { arrayByDifference, nonSecureRandomIdentifier, subtractFromArray } from '@standardnotes/utils'
import { ServerSyncResponse } from '@Lib/Services/Sync/Account/Response'
import { ResponseSignalReceiver, SyncSignal } from '@Lib/Services/Sync/Signals'
import { SNApiService } from '../../Api/ApiService'
import { RawSyncResponse } from '@standardnotes/responses'
export const SyncUpDownLimit = 150
/**
* A long running operation that handles multiple roundtrips from a server,
* emitting a stream of values that should be acted upon in real time.
*/
export class AccountSyncOperation {
public readonly id = nonSecureRandomIdentifier()
private pendingPayloads: ServerSyncPushContextualPayload[]
private responses: ServerSyncResponse[] = []
/**
* @param payloads An array of payloads to send to the server
* @param receiver A function that receives callback multiple times during the operation
*/
constructor(
private payloads: ServerSyncPushContextualPayload[],
private receiver: ResponseSignalReceiver<ServerSyncResponse>,
private lastSyncToken: string,
private paginationToken: string,
private apiService: SNApiService,
) {
this.payloads = payloads
this.lastSyncToken = lastSyncToken
this.paginationToken = paginationToken
this.apiService = apiService
this.receiver = receiver
this.pendingPayloads = payloads.slice()
}
/**
* Read the payloads that have been saved, or are currently in flight.
*/
get payloadsSavedOrSaving(): ServerSyncPushContextualPayload[] {
return arrayByDifference(this.payloads, this.pendingPayloads)
}
popPayloads(count: number) {
const payloads = this.pendingPayloads.slice(0, count)
subtractFromArray(this.pendingPayloads, payloads)
return payloads
}
async run(): Promise<void> {
await this.receiver(SyncSignal.StatusChanged, undefined, {
completedUploadCount: this.totalUploadCount - this.pendingUploadCount,
totalUploadCount: this.totalUploadCount,
})
const payloads = this.popPayloads(this.upLimit)
const rawResponse = (await this.apiService.sync(
payloads,
this.lastSyncToken,
this.paginationToken,
this.downLimit,
)) as RawSyncResponse
const response = new ServerSyncResponse(rawResponse)
this.responses.push(response)
this.lastSyncToken = response.lastSyncToken as string
this.paginationToken = response.paginationToken as string
try {
await this.receiver(SyncSignal.Response, response)
} catch (error) {
console.error('Sync handle response error', error)
}
if (!this.done) {
return this.run()
}
}
get done() {
return this.pendingPayloads.length === 0 && !this.paginationToken
}
private get pendingUploadCount() {
return this.pendingPayloads.length
}
private get totalUploadCount() {
return this.payloads.length
}
private get upLimit() {
return SyncUpDownLimit
}
private get downLimit() {
return SyncUpDownLimit
}
get numberOfItemsInvolved() {
let total = 0
for (const response of this.responses) {
total += response.numberOfItemsInvolved
}
return total
}
}

View File

@@ -0,0 +1,115 @@
import {
ApiEndpointParam,
ConflictParams,
ConflictType,
Error,
RawSyncResponse,
ServerItemResponse,
} from '@standardnotes/responses'
import {
FilterDisallowedRemotePayloadsAndMap,
CreateServerSyncSavedPayload,
ServerSyncSavedContextualPayload,
FilteredServerItem,
} from '@standardnotes/models'
import { deepFreeze, isNullOrUndefined } from '@standardnotes/utils'
export class ServerSyncResponse {
public readonly rawResponse: RawSyncResponse
public readonly savedPayloads: ServerSyncSavedContextualPayload[]
public readonly retrievedPayloads: FilteredServerItem[]
public readonly uuidConflictPayloads: FilteredServerItem[]
public readonly dataConflictPayloads: FilteredServerItem[]
public readonly rejectedPayloads: FilteredServerItem[]
constructor(rawResponse: RawSyncResponse) {
this.rawResponse = rawResponse
this.savedPayloads = FilterDisallowedRemotePayloadsAndMap(rawResponse.data?.saved_items || []).map((rawItem) => {
return CreateServerSyncSavedPayload(rawItem)
})
this.retrievedPayloads = FilterDisallowedRemotePayloadsAndMap(rawResponse.data?.retrieved_items || [])
this.dataConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawDataConflictItems)
this.uuidConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawUuidConflictItems)
this.rejectedPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawRejectedPayloads)
deepFreeze(this)
}
public get error(): Error | undefined {
return this.rawResponse.error || this.rawResponse.data?.error
}
public get status(): number {
return this.rawResponse.status as number
}
public get lastSyncToken(): string | undefined {
return this.rawResponse.data?.[ApiEndpointParam.LastSyncToken]
}
public get paginationToken(): string | undefined {
return this.rawResponse.data?.[ApiEndpointParam.PaginationToken]
}
public get numberOfItemsInvolved(): number {
return this.allFullyFormedPayloads.length
}
private get allFullyFormedPayloads(): FilteredServerItem[] {
return [
...this.retrievedPayloads,
...this.dataConflictPayloads,
...this.uuidConflictPayloads,
...this.rejectedPayloads,
]
}
private get rawUuidConflictItems(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return conflict.type === ConflictType.UuidConflict
})
.map((conflict) => {
return conflict.unsaved_item || (conflict.item as ServerItemResponse)
})
}
private get rawDataConflictItems(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return conflict.type === ConflictType.ConflictingData
})
.map((conflict) => {
return conflict.server_item || (conflict.item as ServerItemResponse)
})
}
private get rawRejectedPayloads(): ServerItemResponse[] {
return this.rawConflictObjects
.filter((conflict) => {
return (
conflict.type === ConflictType.ContentTypeError ||
conflict.type === ConflictType.ContentError ||
conflict.type === ConflictType.ReadOnlyError
)
})
.map((conflict) => {
return conflict.unsaved_item as ServerItemResponse
})
}
private get rawConflictObjects(): ConflictParams[] {
const conflicts = this.rawResponse.data?.conflicts || []
const legacyConflicts = this.rawResponse.data?.unsaved || []
return conflicts.concat(legacyConflicts)
}
public get hasError(): boolean {
return !isNullOrUndefined(this.rawResponse.error)
}
}

View File

@@ -0,0 +1,86 @@
import {
ImmutablePayloadCollection,
HistoryMap,
DeltaRemoteRetrieved,
DeltaRemoteSaved,
DeltaRemoteDataConflicts,
FullyFormedPayloadInterface,
ServerSyncPushContextualPayload,
ServerSyncSavedContextualPayload,
DeltaRemoteUuidConflicts,
DeltaRemoteRejected,
DeltaEmit,
} from '@standardnotes/models'
type PayloadSet = {
retrievedPayloads: FullyFormedPayloadInterface[]
savedPayloads: ServerSyncSavedContextualPayload[]
uuidConflictPayloads: FullyFormedPayloadInterface[]
dataConflictPayloads: FullyFormedPayloadInterface[]
rejectedPayloads: FullyFormedPayloadInterface[]
}
/**
* Given a remote sync response, the resolver applies the incoming changes on top
* of the current base state, and returns what the new global state should look like.
* The response resolver is purely functional and does not modify global state, but instead
* offers the 'recommended' new global state given a sync response and a current base state.
*/
export class ServerSyncResponseResolver {
constructor(
private payloadSet: PayloadSet,
private baseCollection: ImmutablePayloadCollection<FullyFormedPayloadInterface>,
private payloadsSavedOrSaving: ServerSyncPushContextualPayload[],
private historyMap: HistoryMap,
) {}
public result(): DeltaEmit[] {
const emits: DeltaEmit[] = []
emits.push(this.processRetrievedPayloads())
emits.push(this.processSavedPayloads())
emits.push(this.processUuidConflictPayloads())
emits.push(this.processDataConflictPayloads())
emits.push(this.processRejectedPayloads())
return emits
}
private processSavedPayloads(): DeltaEmit {
const delta = new DeltaRemoteSaved(this.baseCollection, this.payloadSet.savedPayloads)
return delta.result()
}
private processRetrievedPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.retrievedPayloads)
const delta = new DeltaRemoteRetrieved(this.baseCollection, collection, this.payloadsSavedOrSaving, this.historyMap)
return delta.result()
}
private processDataConflictPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.dataConflictPayloads)
const delta = new DeltaRemoteDataConflicts(this.baseCollection, collection, this.historyMap)
return delta.result()
}
private processUuidConflictPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.uuidConflictPayloads)
const delta = new DeltaRemoteUuidConflicts(this.baseCollection, collection)
return delta.result()
}
private processRejectedPayloads(): DeltaEmit {
const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.rejectedPayloads)
const delta = new DeltaRemoteRejected(this.baseCollection, collection)
return delta.result()
}
}

View File

@@ -0,0 +1,31 @@
import {
EncryptedPayloadInterface,
DeletedPayloadInterface,
PayloadSource,
DeletedPayload,
EncryptedPayload,
FilteredServerItem,
} from '@standardnotes/models'
export function CreatePayloadFromRawServerItem(
rawItem: FilteredServerItem,
source: PayloadSource,
): EncryptedPayloadInterface | DeletedPayloadInterface {
if (rawItem.deleted) {
return new DeletedPayload({ ...rawItem, content: undefined, deleted: true }, source)
} else if (rawItem.content != undefined) {
return new EncryptedPayload(
{
...rawItem,
items_key_id: rawItem.items_key_id,
content: rawItem.content,
deleted: false,
errorDecrypting: false,
waitingForKey: false,
},
source,
)
} else {
throw Error('Unhandled case in createPayloadFromRawItem')
}
}

View File

@@ -0,0 +1,29 @@
import {
CreateOfflineSyncSavedPayload,
DecryptedPayloadInterface,
DeletedPayloadInterface,
} from '@standardnotes/models'
import { ResponseSignalReceiver, SyncSignal } from '@Lib/Services/Sync/Signals'
import { OfflineSyncResponse } from './Response'
export class OfflineSyncOperation {
/**
* @param payloads An array of payloads to sync offline
* @param receiver A function that receives callback multiple times during the operation
*/
constructor(
private payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[],
private receiver: ResponseSignalReceiver<OfflineSyncResponse>,
) {}
async run() {
const responsePayloads = this.payloads.map((payload) => {
return CreateOfflineSyncSavedPayload(payload)
})
const response = new OfflineSyncResponse(responsePayloads)
await this.receiver(SyncSignal.Response, response)
}
}

View File

@@ -0,0 +1,5 @@
import { OfflineSyncSavedContextualPayload } from '@standardnotes/models'
export class OfflineSyncResponse {
constructor(public readonly savedPayloads: OfflineSyncSavedContextualPayload[]) {}
}

View File

@@ -0,0 +1,18 @@
import { ServerSyncResponse } from '@Lib/Services/Sync/Account/Response'
import { OfflineSyncResponse } from './Offline/Response'
export enum SyncSignal {
Response = 1,
StatusChanged = 2,
}
export type SyncStats = {
completedUploadCount: number
totalUploadCount: number
}
export type ResponseSignalReceiver<T extends ServerSyncResponse | OfflineSyncResponse> = (
signal: SyncSignal,
response?: T,
stats?: SyncStats,
) => Promise<void>

View File

@@ -0,0 +1,12 @@
import { SyncOpStatus } from './SyncOpStatus'
import { SyncOptions } from '@standardnotes/services'
export interface SyncClientInterface {
sync(options?: Partial<SyncOptions>): Promise<unknown>
isOutOfSync(): boolean
getLastSyncDate(): Date | undefined
getSyncStatus(): SyncOpStatus
}

View File

@@ -0,0 +1,125 @@
import { SyncEvent, SyncEventReceiver } from '@standardnotes/services'
const HEALTHY_SYNC_DURATION_THRESHOLD_S = 5
const TIMING_MONITOR_POLL_FREQUENCY_MS = 500
export class SyncOpStatus {
error?: any
private interval: any
private receiver: SyncEventReceiver
private completedUpload = 0
private totalUpload = 0
private downloaded = 0
private databaseLoadCurrent = 0
private databaseLoadTotal = 0
private databaseLoadDone = false
private syncing = false
private syncStart!: Date
private timingMonitor?: any
constructor(interval: any, receiver: SyncEventReceiver) {
this.interval = interval
this.receiver = receiver
}
public deinit() {
this.stopTimingMonitor()
}
public setUploadStatus(completed: number, total: number) {
this.completedUpload = completed
this.totalUpload = total
this.receiver(SyncEvent.StatusChanged)
}
public setDownloadStatus(downloaded: number) {
this.downloaded += downloaded
this.receiver(SyncEvent.StatusChanged)
}
public setDatabaseLoadStatus(current: number, total: number, done: boolean) {
this.databaseLoadCurrent = current
this.databaseLoadTotal = total
this.databaseLoadDone = done
if (done) {
this.receiver(SyncEvent.LocalDataLoaded)
} else {
this.receiver(SyncEvent.LocalDataIncrementalLoad)
}
}
public getStats() {
return {
uploadCompletionCount: this.completedUpload,
uploadTotalCount: this.totalUpload,
downloadCount: this.downloaded,
localDataDone: this.databaseLoadDone,
localDataCurrent: this.databaseLoadCurrent,
localDataTotal: this.databaseLoadTotal,
}
}
public setDidBegin() {
this.syncing = true
this.syncStart = new Date()
}
public setDidEnd() {
this.syncing = false
}
get syncInProgress() {
return this.syncing === true
}
get secondsSinceSyncStart() {
return (new Date().getTime() - this.syncStart.getTime()) / 1000
}
/**
* Notifies receiver if current sync request is taking too long to complete.
*/
startTimingMonitor(): void {
if (this.timingMonitor) {
this.stopTimingMonitor()
}
this.timingMonitor = this.interval(() => {
if (this.secondsSinceSyncStart > HEALTHY_SYNC_DURATION_THRESHOLD_S) {
this.receiver(SyncEvent.SyncTakingTooLong)
this.stopTimingMonitor()
}
}, TIMING_MONITOR_POLL_FREQUENCY_MS)
}
stopTimingMonitor(): void {
if (Object.prototype.hasOwnProperty.call(this.interval, 'cancel')) {
this.interval.cancel(this.timingMonitor)
} else {
clearInterval(this.timingMonitor)
}
this.timingMonitor = null
}
hasError(): boolean {
return !!this.error
}
setError(error: any): void {
this.error = error
}
clearError() {
this.error = null
}
reset() {
this.downloaded = 0
this.completedUpload = 0
this.totalUpload = 0
this.syncing = false
this.error = null
this.stopTimingMonitor()
this.receiver(SyncEvent.StatusChanged)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
import { SyncOptions } from '@standardnotes/services'
export type SyncPromise = {
resolve: (value?: unknown) => void
reject: () => void
options?: SyncOptions
}

View File

@@ -0,0 +1,43 @@
import { ContentType } from '@standardnotes/common'
import { FullyFormedPayloadInterface } from '@standardnotes/models'
/**
* Sorts payloads according by most recently modified first, according to the priority,
* whereby the earlier a content_type appears in the priorityList,
* the earlier it will appear in the resulting sorted array.
*/
export function SortPayloadsByRecentAndContentPriority(
payloads: FullyFormedPayloadInterface[],
priorityList: ContentType[],
): FullyFormedPayloadInterface[] {
return payloads.sort((a, b) => {
const dateResult = new Date(b.serverUpdatedAt).getTime() - new Date(a.serverUpdatedAt).getTime()
let aPriority = 0
let bPriority = 0
if (priorityList) {
aPriority = priorityList.indexOf(a.content_type)
bPriority = priorityList.indexOf(b.content_type)
if (aPriority === -1) {
/** Not found in list, not prioritized. Set it to max value */
aPriority = priorityList.length
}
if (bPriority === -1) {
/** Not found in list, not prioritized. Set it to max value */
bPriority = priorityList.length
}
}
if (aPriority === bPriority) {
return dateResult
}
if (aPriority < bPriority) {
return -1
} else {
return 1
}
})
}

View File

@@ -0,0 +1,9 @@
export * from './SyncService'
export * from './Types'
export * from './SyncOpStatus'
export * from './SyncClientInterface'
export * from './Account/Operation'
export * from './Account/ResponseResolver'
export * from './Offline/Operation'
export * from './Utils'
export * from './Account/Response'

View File

@@ -0,0 +1,5 @@
import { HttpResponse, MinimalHttpResponse } from '@standardnotes/responses'
export interface UserServerInterface {
deleteAccount(userUuid: string): Promise<HttpResponse | MinimalHttpResponse>
}

View File

@@ -0,0 +1,586 @@
import { Challenge } from '../Challenge'
import { ChallengeService } from '../Challenge/ChallengeService'
import { EncryptionService, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption'
import { HttpResponse, SignInResponse, User } from '@standardnotes/responses'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { KeyParamsOrigination } from '@standardnotes/common'
import {
AbstractService,
AlertService,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
DeinitSource,
InternalEventBusInterface,
UserClientInterface,
StoragePersistencePolicies,
} from '@standardnotes/services'
import { SNApiService } from './../Api/ApiService'
import { SNProtectionService } from '../Protection/ProtectionService'
import { SNSessionManager, MINIMUM_PASSWORD_LENGTH } from '../Session/SessionManager'
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
import { SNSyncService } from '../Sync/SyncService'
import { Strings } from '../../Strings/index'
import { UuidGenerator } from '@standardnotes/utils'
import * as Messages from '../Api/Messages'
import { UserRegistrationResponseBody } from '@standardnotes/api'
const MINIMUM_PASSCODE_LENGTH = 1
export type CredentialsChangeFunctionResponse = { error?: { message: string } }
export type AccountServiceResponse = HttpResponse
export enum AccountEvent {
SignedInOrRegistered = 'SignedInOrRegistered',
SignedOut = 'SignedOut',
}
type AccountEventData = {
source: DeinitSource
}
export class UserService extends AbstractService<AccountEvent, AccountEventData> implements UserClientInterface {
private signingIn = false
private registering = false
constructor(
private sessionManager: SNSessionManager,
private syncService: SNSyncService,
private storageService: DiskStorageService,
private itemManager: ItemManager,
private protocolService: EncryptionService,
private alertService: AlertService,
private challengeService: ChallengeService,
private protectionService: SNProtectionService,
private apiService: SNApiService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
public override deinit(): void {
super.deinit()
;(this.sessionManager as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.protocolService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.protectionService as unknown) = undefined
;(this.apiService as unknown) = undefined
}
/**
* @param mergeLocal Whether to merge existing offline data into account. If false,
* any pre-existing data will be fully deleted upon success.
*/
public async register(
email: string,
password: string,
ephemeral = false,
mergeLocal = true,
): Promise<UserRegistrationResponseBody> {
if (this.protocolService.hasAccount()) {
throw Error('Tried to register when an account already exists.')
}
if (this.registering) {
throw Error('Already registering.')
}
this.registering = true
try {
this.lockSyncing()
const response = await this.sessionManager.register(email, password, ephemeral)
this.syncService.resetSyncState()
await this.storageService.setPersistencePolicy(
ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default,
)
if (mergeLocal) {
await this.syncService.markAllItemsAsNeedingSyncAndPersist()
} else {
await this.itemManager.removeAllItemsFromMemory()
await this.clearDatabase()
}
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
this.unlockSyncing()
this.registering = false
await this.syncService.downloadFirstSync(300)
void this.protocolService.decryptErroredPayloads()
return response
} catch (error) {
this.unlockSyncing()
this.registering = false
throw error
}
}
/**
* @param mergeLocal Whether to merge existing offline data into account.
* If false, any pre-existing data will be fully deleted upon success.
*/
public async signIn(
email: string,
password: string,
strict = false,
ephemeral = false,
mergeLocal = true,
awaitSync = false,
): Promise<AccountServiceResponse> {
if (this.protocolService.hasAccount()) {
throw Error('Tried to sign in when an account already exists.')
}
if (this.signingIn) {
throw Error('Already signing in.')
}
this.signingIn = true
try {
/** Prevent a timed sync from occuring while signing in. */
this.lockSyncing()
const result = await this.sessionManager.signIn(email, password, strict, ephemeral)
if (!result.response.error) {
this.syncService.resetSyncState()
await this.storageService.setPersistencePolicy(
ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default,
)
if (mergeLocal) {
await this.syncService.markAllItemsAsNeedingSyncAndPersist()
} else {
void this.itemManager.removeAllItemsFromMemory()
await this.clearDatabase()
}
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
this.unlockSyncing()
const syncPromise = this.syncService
.downloadFirstSync(1_000, {
checkIntegrity: true,
awaitAll: awaitSync,
})
.then(() => {
if (!awaitSync) {
void this.protocolService.decryptErroredPayloads()
}
})
if (awaitSync) {
await syncPromise
await this.protocolService.decryptErroredPayloads()
}
} else {
this.unlockSyncing()
}
return result.response
} finally {
this.signingIn = false
}
}
public async deleteAccount(): Promise<{
error: boolean
message?: string
}> {
if (
!(await this.protectionService.authorizeAction(ChallengeReason.DeleteAccount, {
requireAccountPassword: true,
}))
) {
return {
error: true,
message: Messages.INVALID_PASSWORD,
}
}
const uuid = this.sessionManager.getSureUser().uuid
const response = await this.apiService.deleteAccount(uuid)
if (response.error) {
return {
error: true,
message: response.error.message,
}
}
await this.signOut(true)
void this.alertService.alert(Strings.Info.AccountDeleted)
return {
error: false,
}
}
/**
* A sign in request that occurs while the user was previously signed in, to correct
* for missing keys or storage values. Unlike regular sign in, this doesn't worry about
* performing one of marking all items as needing sync or deleting all local data.
*/
public async correctiveSignIn(rootKey: SNRootKey): Promise<HttpResponse | SignInResponse> {
this.lockSyncing()
const response = await this.sessionManager.bypassChecksAndSignInWithRootKey(rootKey.keyParams.identifier, rootKey)
if (!response.error) {
await this.notifyEvent(AccountEvent.SignedInOrRegistered)
this.unlockSyncing()
void this.syncService.downloadFirstSync(1_000, {
checkIntegrity: true,
})
void this.protocolService.decryptErroredPayloads()
}
this.unlockSyncing()
return response
}
/**
* @param passcode - Changing the account password or email requires the local
* passcode if configured (to rewrap the account key with passcode). If the passcode
* is not passed in, the user will be prompted for the passcode. However if the consumer
* already has reference to the passcode, they can pass it in here so that the user
* is not prompted again.
*/
public async changeCredentials(parameters: {
currentPassword: string
origination: KeyParamsOrigination
validateNewPasswordStrength: boolean
newEmail?: string
newPassword?: string
passcode?: string
}): Promise<CredentialsChangeFunctionResponse> {
const result = await this.performCredentialsChange(parameters)
if (result.error) {
void this.alertService.alert(result.error.message)
}
return result
}
public async signOut(force = false, source = DeinitSource.SignOut): Promise<void> {
const performSignOut = async () => {
await this.sessionManager.signOut()
await this.protocolService.deleteWorkspaceSpecificKeyStateFromDevice()
await this.storageService.clearAllData()
await this.notifyEvent(AccountEvent.SignedOut, { source })
}
if (force) {
await performSignOut()
return
}
const dirtyItems = this.itemManager.getDirtyItems()
if (dirtyItems.length > 0) {
const singular = dirtyItems.length === 1
const didConfirm = await this.alertService.confirm(
`There ${singular ? 'is' : 'are'} ${dirtyItems.length} ${
singular ? 'item' : 'items'
} with unsynced changes. If you sign out, these changes will be lost forever. Are you sure you want to sign out?`,
)
if (didConfirm) {
await performSignOut()
}
} else {
await performSignOut()
}
}
public async performProtocolUpgrade(): Promise<{
success?: true
canceled?: true
error?: { message: string }
}> {
const hasPasscode = this.protocolService.hasPasscode()
const hasAccount = this.protocolService.hasAccount()
const prompts = []
if (hasPasscode) {
prompts.push(
new ChallengePrompt(
ChallengeValidation.LocalPasscode,
undefined,
Messages.ChallengeStrings.LocalPasscodePlaceholder,
),
)
}
if (hasAccount) {
prompts.push(
new ChallengePrompt(
ChallengeValidation.AccountPassword,
undefined,
Messages.ChallengeStrings.AccountPasswordPlaceholder,
),
)
}
const challenge = new Challenge(prompts, ChallengeReason.ProtocolUpgrade, true)
const response = await this.challengeService.promptForChallengeResponse(challenge)
if (!response) {
return { canceled: true }
}
const dismissBlockingDialog = await this.alertService.blockingDialog(
Messages.DO_NOT_CLOSE_APPLICATION,
Messages.UPGRADING_ENCRYPTION,
)
try {
let passcode: string | undefined
if (hasPasscode) {
/* Upgrade passcode version */
const value = response.getValueForType(ChallengeValidation.LocalPasscode)
passcode = value.value as string
}
if (hasAccount) {
/* Upgrade account version */
const value = response.getValueForType(ChallengeValidation.AccountPassword)
const password = value.value as string
const changeResponse = await this.changeCredentials({
currentPassword: password,
newPassword: password,
passcode,
origination: KeyParamsOrigination.ProtocolUpgrade,
validateNewPasswordStrength: false,
})
if (changeResponse?.error) {
return { error: changeResponse.error }
}
}
if (hasPasscode) {
/* Upgrade passcode version */
await this.removePasscodeWithoutWarning()
await this.setPasscodeWithoutWarning(passcode as string, KeyParamsOrigination.ProtocolUpgrade)
}
return { success: true }
} catch (error) {
return { error: error as Error }
} finally {
dismissBlockingDialog()
}
}
public async addPasscode(passcode: string): Promise<boolean> {
if (passcode.length < MINIMUM_PASSCODE_LENGTH) {
return false
}
if (!(await this.protectionService.authorizeAddingPasscode())) {
return false
}
const dismissBlockingDialog = await this.alertService.blockingDialog(
Messages.DO_NOT_CLOSE_APPLICATION,
Messages.SETTING_PASSCODE,
)
try {
await this.setPasscodeWithoutWarning(passcode, KeyParamsOrigination.PasscodeCreate)
return true
} finally {
dismissBlockingDialog()
}
}
public async removePasscode(): Promise<boolean> {
if (!(await this.protectionService.authorizeRemovingPasscode())) {
return false
}
const dismissBlockingDialog = await this.alertService.blockingDialog(
Messages.DO_NOT_CLOSE_APPLICATION,
Messages.REMOVING_PASSCODE,
)
try {
await this.removePasscodeWithoutWarning()
return true
} finally {
dismissBlockingDialog()
}
}
/**
* @returns whether the passcode was successfuly changed or not
*/
public async changePasscode(
newPasscode: string,
origination = KeyParamsOrigination.PasscodeChange,
): Promise<boolean> {
if (newPasscode.length < MINIMUM_PASSCODE_LENGTH) {
return false
}
if (!(await this.protectionService.authorizeChangingPasscode())) {
return false
}
const dismissBlockingDialog = await this.alertService.blockingDialog(
Messages.DO_NOT_CLOSE_APPLICATION,
origination === KeyParamsOrigination.ProtocolUpgrade
? Messages.ProtocolUpgradeStrings.UpgradingPasscode
: Messages.CHANGING_PASSCODE,
)
try {
await this.removePasscodeWithoutWarning()
await this.setPasscodeWithoutWarning(newPasscode, origination)
return true
} finally {
dismissBlockingDialog()
}
}
private async setPasscodeWithoutWarning(passcode: string, origination: KeyParamsOrigination) {
const identifier = UuidGenerator.GenerateUuid()
const key = await this.protocolService.createRootKey(identifier, passcode, origination)
await this.protocolService.setNewRootKeyWrapper(key)
await this.rewriteItemsKeys()
await this.syncService.sync()
}
private async removePasscodeWithoutWarning() {
await this.protocolService.removePasscode()
await this.rewriteItemsKeys()
}
/**
* Allows items keys to be rewritten to local db on local credential status change,
* such as if passcode is added, changed, or removed.
* This allows IndexedDB unencrypted logs to be deleted
* `deletePayloads` will remove data from backing store,
* but not from working memory See:
* https://github.com/standardnotes/desktop/issues/131
*/
private async rewriteItemsKeys(): Promise<void> {
const itemsKeys = this.itemManager.getDisplayableItemsKeys()
const payloads = itemsKeys.map((key) => key.payloadRepresentation())
await this.storageService.forceDeletePayloads(payloads)
await this.syncService.persistPayloads(payloads)
}
private lockSyncing(): void {
this.syncService.lockSyncing()
}
private unlockSyncing(): void {
this.syncService.unlockSyncing()
}
private clearDatabase(): Promise<void> {
return this.storageService.clearAllPayloads()
}
private async performCredentialsChange(parameters: {
currentPassword: string
origination: KeyParamsOrigination
validateNewPasswordStrength: boolean
newEmail?: string
newPassword?: string
passcode?: string
}): Promise<CredentialsChangeFunctionResponse> {
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable(parameters.passcode)
if (canceled) {
return { error: Error(Messages.CredentialsChangeStrings.PasscodeRequired) }
}
if (parameters.newPassword !== undefined && parameters.validateNewPasswordStrength) {
if (parameters.newPassword.length < MINIMUM_PASSWORD_LENGTH) {
return {
error: Error(Messages.InsufficientPasswordMessage(MINIMUM_PASSWORD_LENGTH)),
}
}
}
const accountPasswordValidation = await this.protocolService.validateAccountPassword(parameters.currentPassword)
if (!accountPasswordValidation.valid) {
return {
error: Error(Messages.INVALID_PASSWORD),
}
}
const user = this.sessionManager.getUser() as User
const currentEmail = user.email
const rootKeys = await this.recomputeRootKeysForCredentialChange({
currentPassword: parameters.currentPassword,
currentEmail,
origination: parameters.origination,
newEmail: parameters.newEmail,
newPassword: parameters.newPassword,
})
this.lockSyncing()
/** Now, change the credentials on the server. Roll back on failure */
const result = await this.sessionManager.changeCredentials({
currentServerPassword: rootKeys.currentRootKey.serverPassword as string,
newRootKey: rootKeys.newRootKey,
wrappingKey,
newEmail: parameters.newEmail,
})
this.unlockSyncing()
if (!result.response.error) {
const rollback = await this.protocolService.createNewItemsKeyWithRollback()
await this.protocolService.reencryptItemsKeys()
await this.syncService.sync({ awaitAll: true })
const defaultItemsKey = this.protocolService.getSureDefaultItemsKey()
const itemsKeyWasSynced = !defaultItemsKey.neverSynced
if (!itemsKeyWasSynced) {
await this.sessionManager.changeCredentials({
currentServerPassword: rootKeys.newRootKey.serverPassword as string,
newRootKey: rootKeys.currentRootKey,
wrappingKey,
})
await this.protocolService.reencryptItemsKeys()
await rollback()
await this.syncService.sync({ awaitAll: true })
return { error: Error(Messages.CredentialsChangeStrings.Failed) }
}
}
return result.response
}
private async recomputeRootKeysForCredentialChange(parameters: {
currentPassword: string
currentEmail: string
origination: KeyParamsOrigination
newEmail?: string
newPassword?: string
}): Promise<{ currentRootKey: SNRootKey; newRootKey: SNRootKey }> {
const currentRootKey = await this.protocolService.computeRootKey(
parameters.currentPassword,
(await this.protocolService.getRootKeyParams()) as SNRootKeyParams,
)
const newRootKey = await this.protocolService.createRootKey(
parameters.newEmail ?? parameters.currentEmail,
parameters.newPassword ?? parameters.currentPassword,
parameters.origination,
)
return {
currentRootKey,
newRootKey,
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './UserServerInterface'
export * from './UserService'

View File

@@ -0,0 +1,22 @@
export * from './Actions/ActionsService'
export * from './Api'
export * from './AppService/ApplicationService'
export * from './Challenge'
export * from './ComponentManager'
export * from './Features'
export * from './History'
export * from './Items'
export * from './KeyRecovery/KeyRecoveryService'
export * from './Listed'
export * from './Mfa/MfaService'
export * from './Migration/MigrationService'
export * from './Mutator'
export * from './Payloads'
export * from './Preferences/PreferencesService'
export * from './Protection'
export * from './Session'
export * from './Settings'
export * from './Singleton/SingletonManager'
export * from './Storage/DiskStorageService'
export * from './Sync'
export * from './User'