feat: add snjs package
This commit is contained in:
903
packages/snjs/lib/Services/Api/ApiService.ts
Normal file
903
packages/snjs/lib/Services/Api/ApiService.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
211
packages/snjs/lib/Services/Api/HttpService.ts
Normal file
211
packages/snjs/lib/Services/Api/HttpService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
185
packages/snjs/lib/Services/Api/Messages.ts
Normal file
185
packages/snjs/lib/Services/Api/Messages.ts
Normal 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.",
|
||||
}
|
||||
74
packages/snjs/lib/Services/Api/Paths.ts
Normal file
74
packages/snjs/lib/Services/Api/Paths.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
30
packages/snjs/lib/Services/Api/WebsocketsService.spec.ts
Normal file
30
packages/snjs/lib/Services/Api/WebsocketsService.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
67
packages/snjs/lib/Services/Api/WebsocketsService.ts
Normal file
67
packages/snjs/lib/Services/Api/WebsocketsService.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
7
packages/snjs/lib/Services/Api/index.ts
Normal file
7
packages/snjs/lib/Services/Api/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user