diff --git a/extension/chrome/elements/attachment.ts b/extension/chrome/elements/attachment.ts index c327182fac4..2275cf632ac 100644 --- a/extension/chrome/elements/attachment.ts +++ b/extension/chrome/elements/attachment.ts @@ -364,7 +364,7 @@ export class AttachmentDownloadView extends View { this.size = fileSize || this.size; const progressEl = $('.download_progress'); if (!percent && this.size) { - percent = Math.floor(((received * 0.75) / this.size) * 100); + percent = Math.min(Math.floor((received / this.size) * 100), 100); } if (percent) { progressEl.text(`${Math.min(100, percent)}%`); diff --git a/extension/chrome/elements/pgp_block.ts b/extension/chrome/elements/pgp_block.ts index 1628903e603..93580388ee4 100644 --- a/extension/chrome/elements/pgp_block.ts +++ b/extension/chrome/elements/pgp_block.ts @@ -2,7 +2,7 @@ 'use strict'; -import { Url, Value } from '../../js/common/core/common.js'; +import { Url } from '../../js/common/core/common.js'; import { Assert } from '../../js/common/assert.js'; import { RenderMessage } from '../../js/common/render-message.js'; import { Attachment } from '../../js/common/core/attachment.js'; @@ -14,7 +14,7 @@ import { PgpBlockViewQuoteModule } from './pgp_block_modules/pgp-block-quote-mod import { PgpBlockViewRenderModule } from './pgp_block_modules/pgp-block-render-module.js'; import { CommonHandlers, Ui } from '../../js/common/browser/ui.js'; import { View } from '../../js/common/view.js'; -import { Bm, BrowserMsg } from '../../js/common/browser/browser-msg.js'; +import { BrowserMsg } from '../../js/common/browser/browser-msg.js'; export class PgpBlockView extends View { public readonly acctEmail: string; // needed for attachment decryption, probably should be refactored out @@ -28,7 +28,6 @@ export class PgpBlockView extends View { public readonly renderModule: PgpBlockViewRenderModule; public readonly printModule = new PgpBlockViewPrintModule(); private readonly tabId = BrowserMsg.generateTabId(); - private progressOperation?: { text: string; operationId: string; // to ignore possible stray notifications, we generate an id for each operation @@ -65,32 +64,18 @@ export class PgpBlockView extends View { BrowserMsg.addListener('pgp_block_render', async (msg: RenderMessage) => { this.processMessage(msg); }); - BrowserMsg.addListener('ajax_progress', async (progress: Bm.AjaxProgress) => { - this.handleAjaxProgress(progress); - }); BrowserMsg.addListener('confirmation_result', CommonHandlers.createAsyncResultHandler()); - BrowserMsg.listen([this.getDest(), this.frameId]); // we receive non-critical ajax_progress calls via frameId address + BrowserMsg.listen(this.getDest()); BrowserMsg.send.pgpBlockReady(this, { frameId: this.frameId, messageSender: this.getDest() }); }; - private handleAjaxProgress = ({ operationId, percent, loaded, total, expectedTransferSize }: Bm.AjaxProgress) => { - if (this.progressOperation && this.progressOperation.operationId === operationId) { - const perc = Value.getPercentage(percent, loaded, total, expectedTransferSize); - if (typeof perc !== 'undefined') { - this.renderProgress({ ...this.progressOperation, perc }); - } - return true; - } - return false; - }; - private renderProgress = ({ operationId, text, perc, init }: { operationId: string; text: string; perc?: number; init?: boolean }) => { if (init) { this.progressOperation = { operationId, text }; } else if (this.progressOperation?.operationId !== operationId) { return; } - const renderText = perc ? `${text} ${perc}%` : text; + const renderText = perc ? `${text} ${Math.min(perc, 100)}%` : text; this.renderModule.renderText(renderText); }; diff --git a/extension/chrome/settings/modules/change_passphrase.ts b/extension/chrome/settings/modules/change_passphrase.ts index c5b700ed3a3..696cd2a677a 100644 --- a/extension/chrome/settings/modules/change_passphrase.ts +++ b/extension/chrome/settings/modules/change_passphrase.ts @@ -102,7 +102,7 @@ View.run( const prv = await KeyUtil.parse(this.mostUsefulPrv.keyInfo.private); if ((await KeyUtil.decrypt(prv, String($('#current_pass_phrase').val()))) === true) { await this.bruteForceProtection.passphraseCheckSucceed(); - this.mostUsefulPrv!.key = prv; // eslint-disable-line @typescript-eslint/no-non-null-assertion + this.mostUsefulPrv.key = prv; this.displayBlock('step_1_enter_new'); $('#new_pass_phrase').trigger('focus'); } else { diff --git a/extension/chrome/settings/setup/setup-key-manager-autogen.ts b/extension/chrome/settings/setup/setup-key-manager-autogen.ts index 4330eba3386..d0950019c48 100644 --- a/extension/chrome/settings/setup/setup-key-manager-autogen.ts +++ b/extension/chrome/settings/setup/setup-key-manager-autogen.ts @@ -6,7 +6,7 @@ import { SetupOptions, SetupView } from '../setup.js'; import { Ui } from '../../../js/common/browser/ui.js'; import { Url } from '../../../js/common/core/common.js'; import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; -import { ApiErr } from '../../../js/common/api/shared/api-error.js'; +import { AjaxErr, ApiErr } from '../../../js/common/api/shared/api-error.js'; import { Api } from '../../../js/common/api/shared/api.js'; import { Settings } from '../../../js/common/settings.js'; import { KeyUtil } from '../../../js/common/core/crypto/key.js'; @@ -81,9 +81,16 @@ export class SetupWithEmailKeyManagerModule { } catch (e) { if (ApiErr.isNetErr(e) && (await Api.isInternetAccessible())) { // frendly message when key manager is down, helpful during initial infrastructure setup - e.message = - `FlowCrypt Email Key Manager at ${this.view.clientConfiguration.getKeyManagerUrlForPrivateKeys()} cannot be reached. ` + + const url = this.view.clientConfiguration.getKeyManagerUrlForPrivateKeys(); + const message = + `FlowCrypt Email Key Manager at ${url} cannot be reached. ` + 'If your organization requires a VPN, please connect to it. Else, please inform your network admin.'; + if (e instanceof AjaxErr) { + e.message = message; // we assume isNetErr wasn't identified by the message property + } else { + // convert to AjaxErr, identifiable as a net error, with a custom message + throw AjaxErr.fromNetErr(message, e.stack, url); + } } throw e; } diff --git a/extension/js/background_page/bg-handlers.ts b/extension/js/background_page/bg-handlers.ts index 92d5d9edcd0..95c02e1f1e2 100644 --- a/extension/js/background_page/bg-handlers.ts +++ b/extension/js/background_page/bg-handlers.ts @@ -2,12 +2,12 @@ 'use strict'; -import { Api } from '../common/api/shared/api.js'; import { BgUtils } from './bgutils.js'; -import { Bm, BrowserMsg } from '../common/browser/browser-msg.js'; +import { Bm } from '../common/browser/browser-msg.js'; import { Gmail } from '../common/api/email-provider/gmail/gmail.js'; import { GlobalStore } from '../common/platform/store/global-store.js'; import { ContactStore } from '../common/platform/store/contact-store.js'; +import { Api } from '../common/api/shared/api.js'; export class BgHandlers { public static openSettingsPageHandler: Bm.AsyncResponselessHandler = async ({ page, path, pageUrlParams, addNewAcct, acctEmail }: Bm.Settings) => { @@ -30,16 +30,7 @@ export class BgHandlers { }; public static ajaxHandler = async (r: Bm.Ajax): Promise => { - if (r.req.context?.operationId) { - // progress updates were requested via messages - const frameId = r.req.context.frameId; - const operationId = r.req.context.operationId; - const expectedTransferSize = r.req.context.expectedTransferSize; - r.req.xhr = Api.getAjaxProgressXhrFactory({ - download: (percent, loaded, total) => BrowserMsg.send.ajaxProgress(frameId, { percent, loaded, total, expectedTransferSize, operationId }), - }); - } - return await Api.ajax(r.req, r.stack); + return await Api.ajax(r.req, r.resFmt); }; public static ajaxGmailAttachmentGetChunkHandler = async (r: Bm.AjaxGmailAttachmentGetChunk): Promise => { @@ -61,7 +52,7 @@ export class BgHandlers { if (activeTabs[0].id !== undefined) { type ScriptRes = { acctEmail: string | undefined; sameWorld: boolean | undefined }[]; chrome.tabs.executeScript( - activeTabs[0].id!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + activeTabs[0].id, { code: 'var r = {acctEmail: window.account_email_global, sameWorld: window.same_world_global}; r' }, (result: ScriptRes) => { resolve({ diff --git a/extension/js/common/api/account-servers/external-service.ts b/extension/js/common/api/account-servers/external-service.ts index e9dc1537623..3e75cdec9dd 100644 --- a/extension/js/common/api/account-servers/external-service.ts +++ b/extension/js/common/api/account-servers/external-service.ts @@ -1,7 +1,7 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ 'use strict'; -import { Api, ProgressCb, ProgressCbs, ReqFmt, ReqMethod } from '../shared/api.js'; +import { Api, ProgressCb, ProgressCbs } from '../shared/api.js'; import { AcctStore } from '../../platform/store/acct-store.js'; import { Dict, Str } from '../../core/common.js'; import { ErrorReport } from '../../platform/catch.js'; @@ -12,6 +12,7 @@ import { ParsedRecipients } from '../email-provider/email-provider-api.js'; import { Buf } from '../../core/buf.js'; import { ClientConfigurationError, ClientConfigurationJson } from '../../client-configuration.js'; import { InMemoryStore } from '../../platform/store/in-memory-store.js'; +import { Serializable } from '../../platform/store/abstract-store.js'; import { GoogleOAuth } from '../authentication/google/google-oauth.js'; import { AuthenticationConfiguration } from '../../authentication-configuration.js'; import { Xss } from '../../platform/xss.js'; @@ -86,13 +87,13 @@ export class ExternalService extends Api { }; public getServiceInfo = async (): Promise => { - return await this.request('GET', `/api/`); + return await this.request(`/api/`); }; public fetchAndSaveClientConfiguration = async (): Promise => { - const auth = await this.request('GET', `/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`); + const auth = await this.request(`/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`); await AcctStore.set(this.acctEmail, { authentication: auth }); - const r = await this.request('GET', `/api/${this.apiVersion}/client-configuration?domain=${this.domain}`); + const r = await this.request(`/api/${this.apiVersion}/client-configuration?domain=${this.domain}`); if (r.clientConfiguration && !r.clientConfiguration.flags) { throw new ClientConfigurationError('missing_flags'); } @@ -101,28 +102,26 @@ export class ExternalService extends Api { }; public reportException = async (errorReport: ErrorReport): Promise => { - await this.request('POST', `/api/${this.apiVersion}/log-collector/exception`, {}, errorReport); + await this.request(`/api/${this.apiVersion}/log-collector/exception`, { fmt: 'JSON', data: errorReport }); }; public helpFeedback = async (email: string, message: string): Promise => { - return await this.request('POST', `/api/${this.apiVersion}/account/feedback`, {}, { email, message }); + return await this.request(`/api/${this.apiVersion}/account/feedback`, { fmt: 'JSON', data: { email, message } }); }; public reportEvent = async (tags: EventTag[], message: string, details?: string): Promise => { - await this.request( - 'POST', - `/api/${this.apiVersion}/log-collector/exception`, - {}, - { + await this.request(`/api/${this.apiVersion}/log-collector/exception`, { + fmt: 'JSON', + data: { tags, message, details, - } - ); + }, + }); }; public webPortalMessageNewReplyToken = async (): Promise => { - return await this.request('POST', `/api/${this.apiVersion}/message/new-reply-token`, {}, {}); + return await this.request(`/api/${this.apiVersion}/message/new-reply-token`, { fmt: 'JSON', data: {} }); }; public webPortalMessageUpload = async ( @@ -151,50 +150,52 @@ export class ExternalService extends Api { ), }); const multipartBody = { content, details }; - return await this.request('POST', `/api/${this.apiVersion}/message`, {}, multipartBody, { upload: progressCb }); + return await this.request(`/api/${this.apiVersion}/message`, { fmt: 'FORM', data: multipartBody }, { upload: progressCb }); }; public messageGatewayUpdate = async (externalId: string, emailGatewayMessageId: string) => { - await this.request( - 'POST', - `/api/${this.apiVersion}/message/${externalId}/gateway`, - {}, - { + await this.request(`/api/${this.apiVersion}/message/${externalId}/gateway`, { + fmt: 'JSON', + data: { emailGatewayMessageId, - } - ); + }, + }); }; - private authHdr = async (): Promise> => { + private authHdr = async (): Promise<{ authorization: string }> => { const idToken = await InMemoryStore.getUntilAvailable(this.acctEmail, InMemoryStoreKeys.ID_TOKEN); if (idToken) { - return { Authorization: `Bearer ${idToken}` }; // eslint-disable-line @typescript-eslint/naming-convention + return { authorization: `Bearer ${idToken}` }; } // user will not actually see this message, they'll see a generic login prompt throw new BackendAuthErr('Missing id token, please re-authenticate'); }; - private request = async (method: ReqMethod, path: string, headers: Dict = {}, vals?: Dict, progress?: ProgressCbs): Promise => { - let reqFmt: ReqFmt | undefined; - if (progress) { - reqFmt = 'FORM'; - } else if (method !== 'GET') { - reqFmt = 'JSON'; - } + private request = async ( + path: string, + vals?: + | { + data: Dict; + fmt: 'FORM'; + } + | { data: Dict; fmt: 'JSON' }, + progress?: ProgressCbs + ): Promise => { + const values: + | { + data: Dict; + fmt: 'FORM'; + method: 'POST'; + } + | { data: Dict; fmt: 'JSON'; method: 'POST' } + | undefined = vals + ? { + ...vals, + method: 'POST', + } + : undefined; try { - return await ExternalService.apiCall( - this.url, - path, - vals, - reqFmt, - progress, - { - ...headers, - ...(await this.authHdr()), - }, - 'json', - method - ); + return await ExternalService.apiCall(this.url, path, values, progress, await this.authHdr(), 'json'); } catch (firstAttemptErr) { const idToken = await InMemoryStore.get(this.acctEmail, InMemoryStoreKeys.ID_TOKEN); if (ApiErr.isAuthErr(firstAttemptErr) && idToken) { @@ -204,16 +205,12 @@ export class ExternalService extends Api { return await ExternalService.apiCall( this.url, path, - vals, - reqFmt, + values, progress, { - ...headers, - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: await GoogleOAuth.googleApiAuthHeader(email, true), + authorization: await GoogleOAuth.googleApiAuthHeader(email, true), }, - 'json', - method + 'json' ); } } diff --git a/extension/js/common/api/authentication/google/google-oauth.ts b/extension/js/common/api/authentication/google/google-oauth.ts index 9fd1cf35181..f7f518ac83c 100644 --- a/extension/js/common/api/authentication/google/google-oauth.ts +++ b/extension/js/common/api/authentication/google/google-oauth.ts @@ -5,7 +5,7 @@ import { Url } from '../../../core/common.js'; import { FLAVOR, GOOGLE_OAUTH_SCREEN_HOST, OAUTH_GOOGLE_API_HOST } from '../../../core/const.js'; import { ApiErr } from '../../shared/api-error.js'; -import { Api, ApiCallContext } from '../../shared/api.js'; +import { Ajax, Api } from '../../shared/api.js'; import { Bm, GoogleAuthWindowResult$result } from '../../../browser/browser-msg.js'; import { InMemoryStoreKeys } from '../../../core/const.js'; @@ -85,10 +85,12 @@ export class GoogleOAuth extends OAuth { return (await Api.ajax( { url: `${OAUTH_GOOGLE_API_HOST}/tokeninfo?access_token=${accessToken}`, + method: 'GET', timeout: 10000, + stack: Catch.stackTrace(), }, - Catch.stackTrace() - )) as unknown as GoogleTokenInfo; + 'json' + )) as GoogleTokenInfo; }; public static googleApiAuthHeader = async (acctEmail: string, forceRefresh = false): Promise => { @@ -121,15 +123,16 @@ export class GoogleOAuth extends OAuth { ); }; - public static apiGoogleCallRetryAuthErrorOneTime = async (acctEmail: string, request: JQuery.AjaxSettings): Promise => { + public static apiGoogleCallRetryAuthErrorOneTime = async (acctEmail: string, req: Ajax): Promise => { try { - return await Api.ajax(request, Catch.stackTrace()); + return (await Api.ajax(req, 'json')) as RT; } catch (firstAttemptErr) { if (ApiErr.isAuthErr(firstAttemptErr)) { // force refresh token - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - request.headers!.Authorization = await GoogleOAuth.googleApiAuthHeader(acctEmail, true); - return await Api.ajax(request, Catch.stackTrace()); + return (await Api.ajax( + { ...req, headers: { ...(req.headers ?? {}), authorization: await GoogleOAuth.googleApiAuthHeader(acctEmail, true) }, stack: Catch.stackTrace() }, + 'json' + )) as RT; } throw firstAttemptErr; } @@ -310,30 +313,29 @@ export class GoogleOAuth extends OAuth { }), /* eslint-enable @typescript-eslint/naming-convention */ method: 'POST', - crossDomain: true, - async: true, + stack: Catch.stackTrace(), }, - Catch.stackTrace() - )) as unknown as GoogleAuthTokensResponse; + 'json' + )) as GoogleAuthTokensResponse; }; private static googleAuthRefreshToken = async (refreshToken: string) => { - return (await Api.ajax( - { - /* eslint-disable @typescript-eslint/naming-convention */ - url: Url.create(GoogleOAuth.OAUTH.url_tokens, { - grant_type: 'refresh_token', - refreshToken, - client_id: GoogleOAuth.OAUTH.client_id, - client_secret: GoogleOAuth.OAUTH.client_secret, - }), - /* eslint-enable @typescript-eslint/naming-convention */ - method: 'POST', - crossDomain: true, - async: true, - }, - Catch.stackTrace() - )) as unknown as GoogleAuthTokensResponse; + const url = + /* eslint-disable @typescript-eslint/naming-convention */ + Url.create(GoogleOAuth.OAUTH.url_tokens, { + grant_type: 'refresh_token', + refreshToken, + client_id: GoogleOAuth.OAUTH.client_id, + client_secret: GoogleOAuth.OAUTH.client_secret, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + const req: Ajax = { + url, + method: 'POST', + stack: Catch.stackTrace(), + }; + + return (await Api.ajax(req, 'json')) as GoogleAuthTokensResponse; }; // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/extension/js/common/api/email-provider/gmail/gmail.ts b/extension/js/common/api/email-provider/gmail/gmail.ts index dbfec4d3652..ef3c17bdae2 100644 --- a/extension/js/common/api/email-provider/gmail/gmail.ts +++ b/extension/js/common/api/email-provider/gmail/gmail.ts @@ -3,7 +3,7 @@ 'use strict'; import { AddrParserResult, BrowserWindow } from '../../../browser/browser-window.js'; -import { ChunkedCb, ProgressCb, EmailProviderContact, ProgressDestFrame } from '../../shared/api.js'; +import { ChunkedCb, ProgressCb, EmailProviderContact } from '../../shared/api.js'; import { Dict, Str, Value } from '../../../core/common.js'; import { EmailProviderApi, EmailProviderInterface, Backups } from '../email-provider-api.js'; import { GMAIL_GOOGLE_API_HOST, gmailBackupSearchQuery } from '../../../core/const.js'; @@ -35,52 +35,64 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { }; public threadGet = async (threadId: string, format?: GmailResponseFormat, progressCb?: ProgressCb): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', `threads/${threadId}`, { format }, { download: progressCb }); + return await Google.gmailCall(this.acctEmail, `threads/${threadId}`, { method: 'GET', data: { format } }, { download: progressCb }); }; public threadList = async (labelId: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', `threads`, { - labelIds: labelId !== 'ALL' ? labelId : undefined, - includeSpamTrash: Boolean(labelId === 'SPAM' || labelId === 'TRASH'), - // pageToken: page_token, - // q, - // maxResults + return await Google.gmailCall(this.acctEmail, `threads`, { + method: 'GET', + data: { + labelIds: labelId !== 'ALL' ? labelId : undefined, + includeSpamTrash: Boolean(labelId === 'SPAM' || labelId === 'TRASH'), + // pageToken: page_token, + // q, + // maxResults + }, }); }; public threadModify = async (id: string, rmLabels: string[], addLabels: string[]): Promise => { - return await Google.gmailCall(this.acctEmail, 'POST', `threads/${id}/modify`, { - removeLabelIds: rmLabels || [], // todo - insufficient permission - need https://github.com/FlowCrypt/flowcrypt-browser/issues/1304 - addLabelIds: addLabels || [], + return await Google.gmailCall(this.acctEmail, `threads/${id}/modify`, { + method: 'POST', + data: { + removeLabelIds: rmLabels || [], // todo - insufficient permission - need https://github.com/FlowCrypt/flowcrypt-browser/issues/1304 + addLabelIds: addLabels || [], + }, }); }; public draftCreate = async (mimeMsg: string, threadId: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'POST', 'drafts', { - message: { raw: Buf.fromUtfStr(mimeMsg).toBase64UrlStr(), threadId }, + return await Google.gmailCall(this.acctEmail, 'drafts', { + method: 'POST', + data: { + message: { raw: Buf.fromUtfStr(mimeMsg).toBase64UrlStr(), threadId }, + }, }); }; public draftDelete = async (id: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'DELETE', 'drafts/' + id, undefined); + return await Google.gmailCall(this.acctEmail, 'drafts/' + id, { method: 'DELETE' }); }; public draftUpdate = async (id: string, mimeMsg: string, threadId: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'PUT', `drafts/${id}`, { - message: { raw: Buf.fromUtfStr(mimeMsg).toBase64UrlStr(), threadId }, + return await Google.gmailCall(this.acctEmail, `drafts/${id}`, { + method: 'PUT', + data: { + message: { raw: Buf.fromUtfStr(mimeMsg).toBase64UrlStr(), threadId }, + }, }); }; public draftGet = async (id: string, format: GmailResponseFormat = 'full'): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', `drafts/${id}`, { format }); + return await Google.gmailCall(this.acctEmail, `drafts/${id}`, { method: 'GET', data: { format } }); }; public draftList = async (): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', 'drafts', undefined); + return await Google.gmailCall(this.acctEmail, 'drafts'); }; public draftSend = async (id: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'POST', 'drafts/send', { id }); + return await Google.gmailCall(this.acctEmail, 'drafts/send', { method: 'POST', data: { id } }); }; public msgSend = async (sendableMsg: SendableMsg, progressCb?: ProgressCb): Promise => { @@ -91,14 +103,22 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { 'application/json; charset=UTF-8': jsonPart, 'message/rfc822': mimeMsg, }); - return await Google.gmailCall(this.acctEmail, 'POST', 'messages/send', request.body, cbs, request.contentType); + return await Google.gmailCall( + this.acctEmail, + 'messages/send', + { method: 'POST', data: request.body, contentType: request.contentType, dataType: 'TEXT' }, + cbs + ); }; public msgList = async (q: string, includeDeleted = false, pageToken?: string): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', 'messages', { - q, - includeSpamTrash: includeDeleted, - pageToken, + return await Google.gmailCall(this.acctEmail, 'messages', { + method: 'GET', + data: { + q, + includeSpamTrash: includeDeleted, + pageToken, + }, }); }; @@ -108,7 +128,12 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { * as a Buf instead of a string. */ public msgGet = async (msgId: string, format: GmailResponseFormat, progressCb?: ProgressCb): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', `messages/${msgId}`, { format: format || 'full' }, { download: progressCb }); + return await Google.gmailCall( + this.acctEmail, + `messages/${msgId}`, + { method: 'GET', data: { format: format || 'full' } }, + progressCb ? { download: progressCb } : undefined + ); }; public msgsGet = async (msgIds: string[], format: GmailResponseFormat): Promise => { @@ -116,12 +141,17 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { }; public labelsGet = async (): Promise => { - return await Google.gmailCall(this.acctEmail, 'GET', `labels`, {}); + return await Google.gmailCall(this.acctEmail, 'labels', { method: 'GET' }); }; - public attachmentGet = async (msgId: string, attId: string, progress: { download: ProgressCb } | ProgressDestFrame): Promise => { + public attachmentGet = async (msgId: string, attId: string, progress: { download: ProgressCb }): Promise => { type RawGmailAttRes = { attachmentId: string; size: number; data: string }; - const { attachmentId, size, data } = await Google.gmailCall(this.acctEmail, 'GET', `messages/${msgId}/attachments/${attId}`, {}, progress); + const { attachmentId, size, data } = await Google.gmailCall( + this.acctEmail, + `messages/${msgId}/attachments/${attId}`, + { method: 'GET' }, + progress + ); return { attachmentId, size, data: Buf.fromBase64UrlStr(data) }; // data should be a Buf for ease of passing to/from bg page }; @@ -197,7 +227,7 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { // headers, loading status = r.status; if (status >= 300) { - reject(AjaxErr.fromXhr({ status, readyState: r.readyState }, { method, url }, stack)); + reject(AjaxErr.fromXhr({ status, readyState: r.readyState }, { method, url, stack })); window.clearInterval(responsePollInterval); r.abort(); } @@ -213,7 +243,7 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { } } else { // done as a fail - reject - reject(AjaxErr.fromXhr({ status, readyState: r.readyState }, { method, url }, stack)); + reject(AjaxErr.fromXhr({ status, readyState: r.readyState }, { method, url, stack })); window.clearInterval(responsePollInterval); } } @@ -255,7 +285,7 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { } }; - public fetchAttachment = async (a: Attachment, progressFunction: (expectedTransferSize: number) => { download: ProgressCb } | ProgressDestFrame) => { + public fetchAttachment = async (a: Attachment, progressFunction: (expectedTransferSize: number) => { download: ProgressCb }) => { const expectedTransferSize = a.length * 1.33; // todo: remove code duplication // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const response = await this.attachmentGet(a.msgId!, a.id!, progressFunction(expectedTransferSize)); @@ -309,7 +339,7 @@ export class Gmail extends EmailProviderApi implements EmailProviderInterface { }; public fetchAcctAliases = async (): Promise => { - const res = (await Google.gmailCall(this.acctEmail, 'GET', 'settings/sendAs', {})) as GmailRes.GmailAliases; + const res = await Google.gmailCall(this.acctEmail, 'settings/sendAs'); for (const sendAs of res.sendAs) { sendAs.sendAsEmail = sendAs.sendAsEmail.toLowerCase(); } diff --git a/extension/js/common/api/email-provider/gmail/google.ts b/extension/js/common/api/email-provider/gmail/google.ts index 74d0db968b7..58e9f9ea025 100644 --- a/extension/js/common/api/email-provider/gmail/google.ts +++ b/extension/js/common/api/email-provider/gmail/google.ts @@ -2,13 +2,14 @@ 'use strict'; -import { Api, ProgressCbs, ProgressDestFrame, ReqMethod } from '../../shared/api.js'; -import { Dict, Str } from '../../../core/common.js'; +import { Ajax, ProgressCbs } from '../../shared/api.js'; +import { Dict, Str, UrlParams } from '../../../core/common.js'; import { GMAIL_GOOGLE_API_HOST, PEOPLE_GOOGLE_API_HOST } from '../../../core/const.js'; import { GmailRes } from './gmail-parser.js'; import { GoogleOAuth } from '../../authentication/google/google-oauth.js'; import { Serializable } from '../../../platform/store/abstract-store.js'; +import { Catch } from '../../../platform/catch.js'; export class Google { public static webmailUrl = (acctEmail: string) => { @@ -17,69 +18,72 @@ export class Google { public static gmailCall = async ( acctEmail: string, - method: ReqMethod, path: string, - params: Dict | string | undefined, - progress?: ProgressCbs | ProgressDestFrame, - contentType?: string + params?: + | { + method: 'POST' | 'PUT'; + data: Dict; + dataType?: 'JSON'; + } + | { + method: 'POST'; + data: string; + contentType: string; + dataType: 'TEXT'; + } + | { + method: 'GET'; + data?: UrlParams; + } + | { method: 'DELETE' }, + progress?: ProgressCbs ): Promise => { progress = progress || {}; - let data, url; - if ('upload' in progress) { + let url; + let dataPart: + | { method: 'POST' | 'PUT'; data: Dict; dataType: 'JSON' } + | { method: 'POST'; data: string; contentType: string; dataType: 'TEXT' } + | { method: 'GET'; data?: UrlParams } + | { method: 'DELETE' }; + if (params?.method === 'POST' && params.dataType === 'TEXT') { url = `${GMAIL_GOOGLE_API_HOST}/upload/gmail/v1/users/me/${path}?uploadType=multipart`; - data = params; + dataPart = { method: 'POST', data: params.data, contentType: params.contentType, dataType: 'TEXT' }; } else { url = `${GMAIL_GOOGLE_API_HOST}/gmail/v1/users/me/${path}`; - if (method === 'GET' || method === 'DELETE') { - data = params; + if (params?.method === 'GET') { + dataPart = { method: 'GET', data: params.data }; + } else if (params?.method === 'POST' || params?.method === 'PUT') { + dataPart = { method: params.method, data: params.data, dataType: 'JSON' }; + } else if (params?.method === 'DELETE') { + dataPart = { ...params }; } else { - data = JSON.stringify(params); + dataPart = { method: 'GET' }; } } - contentType = contentType || 'application/json; charset=UTF-8'; - // eslint-disable-next-line @typescript-eslint/naming-convention - const headers = { Authorization: await GoogleOAuth.googleApiAuthHeader(acctEmail) }; - const context = - 'operationId' in progress - ? { operationId: progress.operationId, expectedTransferSize: progress.expectedTransferSize, frameId: progress.frameId } - : undefined; - const xhr = Api.getAjaxProgressXhrFactory('download' in progress || 'upload' in progress ? progress : {}); - const request = { xhr, context, url, method, data, headers, crossDomain: true, contentType, async: true }; - return (await GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, request)) as RT; + const headers = { authorization: await GoogleOAuth.googleApiAuthHeader(acctEmail) }; + const progressCbs = 'download' in progress || 'upload' in progress ? progress : undefined; + const request: Ajax = { url, headers, ...dataPart, stack: Catch.stackTrace(), progress: progressCbs }; + return await GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, request); }; public static contactsGet = async (acctEmail: string, query?: string, progress?: ProgressCbs, max = 10) => { progress = progress || {}; - const method = 'GET'; - const contentType = 'application/json; charset=UTF-8'; const searchContactsUrl = `${PEOPLE_GOOGLE_API_HOST}/v1/people:searchContacts`; const searchOtherContactsUrl = `${PEOPLE_GOOGLE_API_HOST}/v1/otherContacts:search`; const data = { query, readMask: 'names,emailAddresses', pageSize: max }; - const xhr = Api.getAjaxProgressXhrFactory(progress); - // eslint-disable-next-line @typescript-eslint/naming-convention - const headers = { Authorization: await GoogleOAuth.googleApiAuthHeader(acctEmail) }; - const contacts = await Promise.all([ - GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, { - xhr, - url: searchContactsUrl, - method, - data, - headers, - contentType, - crossDomain: true, - async: true, - }) as Promise, - GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, { - xhr, - url: searchOtherContactsUrl, - method, - data, - headers, - contentType, - crossDomain: true, - async: true, - }) as Promise, - ]); + const authorization = await GoogleOAuth.googleApiAuthHeader(acctEmail); + const contacts = await Promise.all( + [searchContactsUrl, searchOtherContactsUrl].map(url => + GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, { + progress, + url, + method: 'GET', + data, + headers: { authorization }, + stack: Catch.stackTrace(), + }) + ) + ); const userContacts = contacts[0].results || []; const otherContacts = contacts[1].results || []; const contactsMerged = [...userContacts, ...otherContacts]; @@ -94,20 +98,16 @@ export class Google { }; public static getNames = async (acctEmail: string) => { - const method = 'GET'; - const contentType = 'application/json; charset=UTF-8'; const getProfileUrl = `${PEOPLE_GOOGLE_API_HOST}/v1/people/me`; const data = { personFields: 'names' }; // eslint-disable-next-line @typescript-eslint/naming-convention - const headers = { Authorization: await GoogleOAuth.googleApiAuthHeader(acctEmail) }; + const authorization = await GoogleOAuth.googleApiAuthHeader(acctEmail); const contacts = GoogleOAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, { url: getProfileUrl, - method, + method: 'GET', data, - headers, - contentType, - crossDomain: true, - async: true, + headers: { authorization }, + stack: Catch.stackTrace(), }) as Promise; return contacts; }; diff --git a/extension/js/common/api/flowcrypt-website.ts b/extension/js/common/api/flowcrypt-website.ts index 6b39a8945e8..950f92d42f1 100644 --- a/extension/js/common/api/flowcrypt-website.ts +++ b/extension/js/common/api/flowcrypt-website.ts @@ -24,7 +24,8 @@ export class FlowCryptWebsite extends Api { }; public static retrieveBlogPosts = async (): Promise => { - const xml = (await Api.ajax({ url: 'https://flowcrypt.com/blog/feed.xml', dataType: 'xml' }, Catch.stackTrace())) as XMLDocument; + const xmlString = await Api.ajax({ url: 'https://flowcrypt.com/blog/feed.xml', method: 'GET', stack: Catch.stackTrace() }, 'text'); + const xml = $.parseXML(xmlString); const posts: FlowCryptWebsiteRes.FcBlogPost[] = []; for (const post of Browser.arrFromDomNodeList(xml.querySelectorAll('entry'))) { const children = Browser.arrFromDomNodeList(post.childNodes); diff --git a/extension/js/common/api/key-server/attester.ts b/extension/js/common/api/key-server/attester.ts index 867241b0619..52392e5a722 100644 --- a/extension/js/common/api/key-server/attester.ts +++ b/extension/js/common/api/key-server/attester.ts @@ -2,15 +2,14 @@ 'use strict'; -import { Api, ReqMethod } from './../shared/api.js'; +import { Api } from './../shared/api.js'; import { Dict, Str } from '../../core/common.js'; import { PubkeysSearchResult } from './../pub-lookup.js'; import { AjaxErr, ApiErr } from '../shared/api-error.js'; import { ClientConfiguration } from '../../client-configuration'; import { ATTESTER_API_HOST } from '../../core/const.js'; import { MsgBlockParser } from '../../core/msg-block-parser.js'; - -type PubCallRes = { responseText: string; getResponseHeader: (n: string) => string | null }; +import { Serializable } from '../../platform/store/abstract-store.js'; export class Attester extends Api { public constructor(private clientConfiguration: ClientConfiguration) { @@ -75,7 +74,7 @@ export class Attester extends Api { if (!this.clientConfiguration.canSubmitPubToAttester()) { throw new Error('Cannot replace pubkey at attester because your organisation rules forbid it'); } - await this.pubCall(`pub/${email}`, 'POST', pubkey, { authorization: `Bearer ${idToken}` }); + await this.pubCall(`pub/${email}`, pubkey, { authorization: `Bearer ${idToken}` }); }; /** @@ -87,26 +86,25 @@ export class Attester extends Api { if (!this.clientConfiguration.canSubmitPubToAttester()) { throw new Error('Cannot replace pubkey at attester because your organisation rules forbid it'); } - const r = await this.pubCall(`pub/${email}`, 'POST', pubkey); - return r.responseText; + return await this.pubCall(`pub/${email}`, pubkey); }; public welcomeMessage = async (email: string, pubkey: string, idToken: string | undefined): Promise<{ sent: boolean }> => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const headers = idToken ? { authorization: `Bearer ${idToken!}` } : undefined; - return await this.jsonCall<{ sent: boolean }>('welcome-message', { email, pubkey }, 'POST', headers); + return await this.jsonPost<{ sent: boolean }>('welcome-message', { email, pubkey }, headers); }; - private jsonCall = async (path: string, values?: Dict, method: ReqMethod = 'POST', hdrs?: Dict): Promise => { - return (await Api.apiCall(ATTESTER_API_HOST, path, values, 'JSON', undefined, { 'api-version': '3', ...(hdrs ?? {}) }, 'json', method)) as RT; + private jsonPost = async (path: string, values: Dict, hdrs?: Dict): Promise => { + return (await Api.apiCall(ATTESTER_API_HOST, path, { data: values, fmt: 'JSON' }, undefined, { 'api-version': '3', ...(hdrs ?? {}) }, 'json')) as RT; }; - private pubCall = async (resource: string, method: ReqMethod = 'GET', data?: string | undefined, hdrs?: Dict): Promise => { - return await Api.apiCall(ATTESTER_API_HOST, resource, data, typeof data === 'string' ? 'TEXT' : undefined, undefined, hdrs, 'xhr', method); + private pubCall = async (resource: string, data?: string, hdrs?: Dict): Promise => { + return await Api.apiCall(ATTESTER_API_HOST, resource, typeof data === 'string' ? { data, fmt: 'TEXT' } : undefined, undefined, hdrs, 'text'); }; - private getPubKeysSearchResult = async (r: PubCallRes): Promise => { - const { blocks } = MsgBlockParser.detectBlocks(r.responseText); + private getPubKeysSearchResult = async (r: string): Promise => { + const { blocks } = MsgBlockParser.detectBlocks(r); const pubkeys = blocks.filter(block => block.type === 'publicKey').map(block => Str.with(block.content)); return { pubkeys }; }; diff --git a/extension/js/common/api/key-server/key-manager.ts b/extension/js/common/api/key-server/key-manager.ts index 27943fcf1d7..3bba2be8984 100644 --- a/extension/js/common/api/key-server/key-manager.ts +++ b/extension/js/common/api/key-server/key-manager.ts @@ -2,8 +2,8 @@ 'use strict'; -import { Api, ReqMethod } from './../shared/api.js'; -import { Dict, Url } from '../../core/common.js'; +import { Api } from './../shared/api.js'; +import { Url } from '../../core/common.js'; type LoadPrvRes = { privateKeys: { decryptedPrivateKey: string }[] }; @@ -16,23 +16,16 @@ export class KeyManager extends Api { } public getPrivateKeys = async (idToken: string): Promise => { - return (await this.request('GET', '/v1/keys/private', undefined, idToken)) as LoadPrvRes; + return await Api.apiCall(this.url, '/v1/keys/private', undefined, undefined, idToken ? { authorization: `Bearer ${idToken}` } : undefined, 'json'); }; public storePrivateKey = async (idToken: string, privateKey: string): Promise => { - return await this.request('PUT', '/v1/keys/private', { privateKey }, idToken); - }; - - private request = async (method: ReqMethod, path: string, vals?: Dict | undefined, idToken?: string): Promise => { - return await Api.apiCall( + await Api.apiCall( this.url, - path, - vals, - vals ? 'JSON' : undefined, - undefined, - idToken ? { Authorization: `Bearer ${idToken}` } : undefined, // eslint-disable-line @typescript-eslint/naming-convention + '/v1/keys/private', + { data: { privateKey }, fmt: 'JSON', method: 'PUT' }, undefined, - method + idToken ? { authorization: `Bearer ${idToken}` } : undefined ); }; } diff --git a/extension/js/common/api/key-server/keys-openpgp-org.ts b/extension/js/common/api/key-server/keys-openpgp-org.ts index 60f528757a4..d48cac8efe0 100644 --- a/extension/js/common/api/key-server/keys-openpgp-org.ts +++ b/extension/js/common/api/key-server/keys-openpgp-org.ts @@ -20,16 +20,14 @@ export class KeysOpenpgpOrg extends Api { return { pubkeys: [] }; } try { - const { responseText } = (await Api.apiCall( + const responseText: string = await Api.apiCall( KEYS_OPENPGP_ORG_API_HOST, `vks/v1/by-email/${encodeURIComponent(email)}`, undefined, undefined, undefined, - undefined, - 'xhr', - 'GET' - )) as XMLHttpRequest; + 'text' + ); return { pubkeys: [responseText] }; } catch (e) { /** diff --git a/extension/js/common/api/key-server/sks.ts b/extension/js/common/api/key-server/sks.ts index 4cdd2bbcf81..e31b02070e5 100644 --- a/extension/js/common/api/key-server/sks.ts +++ b/extension/js/common/api/key-server/sks.ts @@ -72,7 +72,7 @@ export class Sks extends Api { private get = async (path: string): Promise => { try { - const { responseText } = (await Api.apiCall(this.url, path, undefined, undefined, undefined, undefined, 'xhr', 'GET')) as XMLHttpRequest; + const responseText = await Api.apiCall(this.url, path, undefined, undefined, undefined, 'text'); return responseText; } catch (e) { if (ApiErr.isNotFound(e)) { diff --git a/extension/js/common/api/shared/api-error.ts b/extension/js/common/api/shared/api-error.ts index 9d3cdf6b94b..ed19cd4acab 100644 --- a/extension/js/common/api/shared/api-error.ts +++ b/extension/js/common/api/shared/api-error.ts @@ -30,12 +30,12 @@ export class GoogleAuthErr extends AuthErr {} export class BackendAuthErr extends AuthErr {} abstract class ApiCallErr extends Error { - protected static describeApiAction = (req: JQueryAjaxSettings) => { + protected static describeApiAction = (req: { url: string; method?: string; data?: unknown }) => { const describeBody = typeof req.data === 'undefined' ? '(no body)' : typeof req.data; return `${req.method || 'GET'}-ing ${Catch.censoredUrl(req.url)} ${describeBody}: ${ApiCallErr.getPayloadStructure(req)}`; }; - private static getPayloadStructure = (req: JQueryAjaxSettings): string => { + private static getPayloadStructure = (req: { data?: unknown }): string => { if (typeof req.data === 'string') { try { return Object.keys(JSON.parse(req.data) as string).join(','); @@ -68,10 +68,14 @@ export class AjaxErr extends ApiCallErr { super(message); } + public static fromNetErr = (message: string, stack?: string, url?: string) => { + return new AjaxErr(message, stack ?? 'no stack trace available', 0, Catch.censoredUrl(url), '', '(no status text)', undefined, undefined); + }; + // no static props, else will get serialised into err reports. Static methods ok - public static fromXhr = (xhr: RawAjaxErr, req: JQueryAjaxSettings, stack: string) => { + public static fromXhr = (xhr: RawAjaxErr, req: { url: string; stack: string; method?: string; data?: unknown }) => { const responseText = xhr.responseText || ''; - stack += `\n\nprovided ajax call stack:\n${stack}`; + let stack = `\n\nprovided ajax call stack:\n${req.stack}`; const { resMsg, resDetails, resCode } = AjaxErr.parseResErr(responseText); const status = resCode || (typeof xhr.status === 'number' ? xhr.status : -1); if (status === 400 || status === 403 || (status === 200 && responseText && responseText[0] !== '{')) { diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index 7ebd7fe44a6..c6ec1c26699 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -3,34 +3,69 @@ 'use strict'; import { Attachment } from '../../core/attachment.js'; -import { BrowserMsg } from '../../browser/browser-msg.js'; import { Buf } from '../../core/buf.js'; import { Catch } from '../../platform/catch.js'; -import { Dict, EmailParts } from '../../core/common.js'; -import { Env } from '../../browser/env.js'; +import { Dict, EmailParts, HTTP_STATUS_TEXTS, Url, UrlParams, Value } from '../../core/common.js'; import { secureRandomBytes } from '../../platform/util.js'; import { ApiErr, AjaxErr } from './api-error.js'; +import { Serializable } from '../../platform/store/abstract-store.js'; +import { Env } from '../../browser/env.js'; +import { BrowserMsg } from '../../browser/browser-msg.js'; export type ReqFmt = 'JSON' | 'FORM' | 'TEXT'; +export type ProgressDestFrame = { operationId: string; expectedTransferSize: number; frameId: string }; +export type ApiCallContext = ProgressDestFrame | undefined; + export type RecipientType = 'to' | 'cc' | 'bcc'; -type ResFmt = 'json' | 'xhr'; +export type ResFmt = 'json' | 'text' | undefined; export type ReqMethod = 'POST' | 'GET' | 'DELETE' | 'PUT'; export type EmailProviderContact = EmailParts; type ProviderContactsResults = { new: EmailProviderContact[]; all: EmailProviderContact[] }; +export type AjaxHeaders = { + authorization?: string; + ['api-version']?: string; +}; +export type Ajax = { + url: string; + headers?: AjaxHeaders; + progress?: ProgressCbs; + timeout?: number; // todo: implement + stack: string; +} & ( + | { method: 'GET' | 'DELETE'; data?: UrlParams } + | { method: 'POST' } + | { method: 'POST' | 'PUT'; data: Dict; dataType: 'JSON' } + | { method: 'POST' | 'PUT'; contentType?: string; data: string; dataType: 'TEXT' } + | { method: 'POST' | 'PUT'; data: FormData; dataType: 'FORM' } + | { method: never; data: never; contentType: never } +); type RawAjaxErr = { - // getAllResponseHeaders?: () => any, - // getResponseHeader?: (e: string) => any, readyState: number; responseText?: string; status?: number; statusText?: string; }; -export type ProgressDestFrame = { operationId: string; expectedTransferSize: number; frameId: string }; -export type ApiCallContext = ProgressDestFrame | undefined; export type ChunkedCb = (r: ProviderContactsResults) => Promise; export type ProgressCb = (percent: number | undefined, loaded: number, total: number) => void; -export type ProgressCbs = { upload?: ProgressCb | null; download?: ProgressCb | null }; +export type ProgressCbs = { upload?: ProgressCb | null; download?: ProgressCb | null; operationId?: string; expectedTransferSize?: number; frameId?: string }; + +type FetchResult = T extends undefined ? undefined : T extends 'text' ? string : RT; + +export const supportsRequestStreams = (() => { + let duplexAccessed = false; + + const hasContentType = new Request('https://localhost', { + body: new ReadableStream(), + method: 'POST', + get duplex() { + duplexAccessed = true; + return 'half'; + }, + } as RequestInit).headers.has('Content-Type'); + + return duplexAccessed && !hasContentType; +})(); export class Api { public static download = async (url: string, progress?: ProgressCb, timeout?: number): Promise => { @@ -50,7 +85,7 @@ export class Api { reject(new Error(`Api.download(${url}) failed with a null progressEvent.target`)); } else { const { readyState, status, statusText } = progressEvent.target as XMLHttpRequest; - reject(AjaxErr.fromXhr({ readyState, status, statusText }, { url, method: 'GET' }, Catch.stackTrace())); + reject(AjaxErr.fromXhr({ readyState, status, statusText }, { url, method: 'GET', stack: Catch.stackTrace() })); } }; request.onerror = errHandler; @@ -60,25 +95,201 @@ export class Api { }); }; - public static ajax = async (req: JQuery.AjaxSettings, stack: string): Promise> => { + public static ajax = async (req: Ajax, resFmt: T): Promise> => { if (Env.isContentScript()) { // content script CORS not allowed anymore, have to drag it through background page // https://www.chromestatus.com/feature/5629709824032768 + if (req.progress) { + req.progress = JSON.parse(JSON.stringify(req.progress)); + } // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return await BrowserMsg.send.bg.await.ajax({ req, stack }); + return await BrowserMsg.send.bg.await.ajax({ req, resFmt }); + } + Api.throwIfApiPathTraversalAttempted(req.url); + const headersInit: [string, string][] = req.headers ? Object.entries(req.headers) : []; + // capitalize? .map(([key, value]) => { return [Str.capitalize(key), value]; }) + const newTimeoutPromise = (): Promise => { + return new Promise((_resolve, reject) => { + /* error-handled */ setTimeout(() => { + reject(AjaxErr.fromXhr({ readyState, status: -1, statusText: 'timeout' }, reqContext)); // Reject the promise with a timeout error + }, req.timeout ?? 20000); + }); + }; + let body: BodyInit | undefined; + let duplex: 'half' | undefined; + let uploadPromise: () => void | Promise = Value.noop; + let url: string; + if (req.method === 'GET' || req.method === 'DELETE') { + if (typeof req.data === 'undefined') { + url = req.url; + } else { + url = Url.create(req.url, req.data, false); + } + } else { + url = req.url; + if (req.method === 'PUT' || req.method === 'POST') { + if ('data' in req && typeof req.data !== 'undefined') { + if (req.dataType === 'JSON') { + body = JSON.stringify(req.data); + headersInit.push(['Content-Type', 'application/json; charset=UTF-8']); + } else if (req.dataType === 'TEXT') { + if (supportsRequestStreams && req.progress?.upload) { + const upload = req.progress?.upload; + const transformStream = new TransformStream(); + uploadPromise = async () => { + const transformWriter = transformStream.writable.getWriter(); + for (let offset = 0; offset < req.data.length; ) { + const chunkSize = Math.min(1000, req.data.length - offset); + await Promise.race([transformWriter.write(Buf.fromRawBytesStr(req.data, offset, offset + chunkSize)), newTimeoutPromise()]); + upload((offset / req.data.length) * 100, offset, req.data.length); + offset += chunkSize; + } + await Promise.race([transformWriter.close(), newTimeoutPromise()]); + }; + body = transformStream.readable; + duplex = 'half'; // activate upload progress mode + } else { + body = req.data; + } + if (typeof req.contentType === 'string') { + headersInit.push(['Content-Type', req.contentType]); + } + } else { + body = req.data; // todo: form data content-type? + } + } + } + } + const abortController = new AbortController(); + const requestInit: RequestInit & { duplex?: 'half' } = { + method: req.method, + headers: headersInit, + body, + duplex, + mode: 'cors', + signal: abortController.signal, + }; + let readyState = 1; // OPENED + const reqContext = { url: req.url, method: req.method, data: body, stack: req.stack }; + try { + const fetchPromise = Api.fetchWithRetry(url, requestInit); + await uploadPromise(); + const response = await Promise.race([fetchPromise, newTimeoutPromise()]); + if (!response.ok) { + let responseText: string | undefined; + readyState = 2; // HEADERS_RECEIVED + try { + readyState = 3; // LOADING + responseText = await response.text(); + readyState = 4; // DONE + } catch { + // continue processing without reponseText + } + throw AjaxErr.fromXhr( + { + readyState, + responseText, + status: response.status, + statusText: response.statusText || HTTP_STATUS_TEXTS[response.status], + }, + reqContext + ); + } + const transformResponseWithProgressAndTimeout = () => { + if (req.progress && response.body) { + const contentLength = response.headers.get('content-length'); + // real content length is approximately 140% of content-length header value + const total = contentLength ? parseInt(contentLength) * 1.4 : 0; + const transformStream = new TransformStream(); + const transformWriter = transformStream.writable.getWriter(); + const reader = response.body.getReader(); + const downloadProgress = req.progress.download; + return { + pipe: async () => { + let downloadedBytes = 0; + while (true) { + const { done, value } = await Promise.race([reader.read(), newTimeoutPromise()]); + if (done) { + await transformWriter.close(); + return; + } + downloadedBytes += value.length; + if (downloadProgress) { + downloadProgress(undefined, downloadedBytes, total); + } else if (req.progress?.expectedTransferSize && req.progress.operationId) { + BrowserMsg.send.ajaxProgress('broadcast', { + percent: undefined, + loaded: downloadedBytes, + total, + expectedTransferSize: req.progress.expectedTransferSize, + operationId: req.progress.operationId, + }); + } + await transformWriter.write(value); + } + }, + response: new Response(transformStream.readable, { + status: response.status, + headers: response.headers, + }), + }; + } else { + return { response, pipe: Value.noop }; // original response + } + }; + if (resFmt === 'text') { + const transformed = transformResponseWithProgressAndTimeout(); + return (await Promise.all([transformed.response.text(), transformed.pipe()]))[0] as FetchResult; + } else if (resFmt === 'json') { + const transformed = transformResponseWithProgressAndTimeout(); + return (await Promise.all([transformed.response.json(), transformed.pipe()]))[0] as FetchResult; + } else { + return undefined as FetchResult; + } + } catch (e) { + if (e instanceof Error) { + if (e.name === 'AbortError') { + // we assume there was a timeout + throw AjaxErr.fromXhr({ readyState, status: -1, statusText: 'timeout' }, reqContext); + } + if (e.name === 'TypeError' && ApiErr.isNetErr(e)) { + // generic failed to fetch + throw AjaxErr.fromXhr({ readyState, status: 0, statusText: 'error' }, reqContext); + } + throw e; + } + throw new Error(`Unknown fetch error (${String(e)}) type when calling ${req.url}`); + } finally { + abortController.abort(); } + }; + + /** @deprecated should use ajax() */ + public static ajaxWithJquery = async ( + req: Ajax, + resFmt: T, + formattedData: FormData | string | undefined = undefined + ): Promise> => { + const apiReq: JQuery.AjaxSettings = { + xhr: Api.getAjaxProgressXhrFactory(req.progress), + url: req.url, + method: req.method, + data: formattedData, + dataType: resFmt, + crossDomain: true, + headers: req.headers, + processData: false, + contentType: false, + async: true, + timeout: typeof req.progress?.upload === 'function' || typeof req.progress?.download === 'function' ? undefined : 20000, // substituted with {} above + }; + try { return await new Promise((resolve, reject) => { Api.throwIfApiPathTraversalAttempted(req.url || ''); - $.ajax({ ...req, dataType: req.dataType === 'xhr' ? undefined : req.dataType }) - .then((data, s, xhr) => { - if (req.dataType === 'xhr') { - // @ts-expect-error -> prevent the xhr object from getting further "resolved" and processed by jQuery, below - xhr.then = xhr.promise = undefined; - resolve(xhr); - } else { - resolve(data as unknown); - } + $.ajax({ ...apiReq, dataType: apiReq.dataType === 'xhr' ? undefined : apiReq.dataType }) + .then(data => { + resolve(data as FetchResult); }) .catch(reject); }); @@ -87,7 +298,7 @@ export class Api { throw e; } if (Api.isRawAjaxErr(e)) { - throw AjaxErr.fromXhr(e, req, stack); + throw AjaxErr.fromXhr(e, { ...req, stack: Catch.stackTrace() }); } throw new Error(`Unknown Ajax error (${String(e)}) type when calling ${req.url}`); } @@ -105,7 +316,81 @@ export class Api { } }; - public static getAjaxProgressXhrFactory = (progressCbs: ProgressCbs | undefined): (() => XMLHttpRequest) | undefined => { + public static randomFortyHexChars = (): string => { + const bytes = Array.from(secureRandomBytes(20)); + return bytes.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join(''); + }; + + public static isRecipientHeaderNameType = (value: string): value is 'to' | 'cc' | 'bcc' => { + return ['to', 'cc', 'bcc'].includes(value); + }; + + protected static apiCall = async ( + url: string, + path: string, + values: + | { + data: Dict; + fmt: 'JSON'; + method?: 'POST' | 'PUT'; + } + | { + data: string; + fmt: 'TEXT'; + method?: 'POST' | 'PUT'; + } + | { + data: Dict; + fmt: 'FORM'; + method?: 'POST' | 'PUT'; + } + | undefined, + progress?: ProgressCbs, + headers?: AjaxHeaders, + resFmt?: T + ): Promise> => { + progress = progress || ({} as ProgressCbs); + let formattedData: FormData | string | undefined; + let dataPart: + | { method: 'GET' } + | { method: 'POST' | 'PUT'; data: Dict; dataType: 'JSON' } + | { method: 'POST' | 'PUT'; data: string; dataType: 'TEXT' } + | { method: 'POST' | 'PUT'; data: FormData; dataType: 'FORM' }; + dataPart = { method: 'GET' }; + if (values) { + if (values.fmt === 'JSON') { + dataPart = { method: values.method ?? 'POST', data: values.data, dataType: 'JSON' }; + } else if (values.fmt === 'TEXT') { + dataPart = { method: values.method ?? 'POST', data: values.data, dataType: 'TEXT' }; + } else if (values.fmt === 'FORM') { + formattedData = new FormData(); + for (const [formFieldName, a] of Object.entries(values.data)) { + if (a instanceof Attachment) { + formattedData.append(formFieldName, new Blob([a.getData()], { type: a.type }), a.name); // xss-none + } else { + formattedData.append(formFieldName, a); // xss-none + } + } + dataPart = { method: values.method ?? 'POST', data: formattedData, dataType: 'FORM' }; + } + } + const req: Ajax = { url: url + path, stack: Catch.stackTrace(), ...dataPart, headers, progress }; + if (typeof resFmt === 'undefined') { + const undefinedRes: undefined = await Api.ajax(req, undefined); // we should get an undefined + return undefinedRes as FetchResult; + } + if (progress.upload) { + // as of October 2023 fetch upload progress (through ReadableStream) + // is supported only by Chrome and requires HTTP/2 on backend + // as temporary solution we use XMLHTTPRequest for such requests + const result = await Api.ajaxWithJquery(req, resFmt, formattedData); + return result as FetchResult; + } else { + return await Api.ajax(req, resFmt); + } + }; + + private static getAjaxProgressXhrFactory = (progressCbs: ProgressCbs | undefined): (() => XMLHttpRequest) | undefined => { if (Env.isContentScript() || !progressCbs || !(progressCbs.upload || progressCbs.download)) { // xhr object would cause 'The object could not be cloned.' lastError during BrowserMsg passing // thus no progress callbacks in bg or content scripts @@ -147,69 +432,6 @@ export class Api { }; }; - public static randomFortyHexChars = (): string => { - const bytes = Array.from(secureRandomBytes(20)); - return bytes.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join(''); - }; - - public static isRecipientHeaderNameType = (value: string): value is 'to' | 'cc' | 'bcc' => { - return ['to', 'cc', 'bcc'].includes(value); - }; - - protected static apiCall = async ( - url: string, - path: string, - fields?: Dict | string, - fmt?: ReqFmt, - progress?: ProgressCbs, - headers?: Dict, - resFmt: ResFmt = 'json', - method: ReqMethod = 'POST' - ): Promise => { - progress = progress || ({} as ProgressCbs); - let formattedData: FormData | string | undefined; - let contentType: string | false; - if (fmt === 'JSON' && fields) { - formattedData = JSON.stringify(fields); - contentType = 'application/json; charset=UTF-8'; - } else if (fmt === 'TEXT' && typeof fields === 'string') { - formattedData = fields; - contentType = false; - } else if (fmt === 'FORM' && fields && typeof fields !== 'string') { - formattedData = new FormData(); - for (const formFieldName of Object.keys(fields)) { - const a: Attachment | string = fields[formFieldName] as Attachment | string; - if (a instanceof Attachment) { - formattedData.append(formFieldName, new Blob([a.getData()], { type: a.type }), a.name); // xss-none - } else { - formattedData.append(formFieldName, a); // xss-none - } - } - contentType = false; - } else if (!fmt && !fields && method === 'GET') { - formattedData = undefined; - contentType = false; - } else { - throw new Error('unknown format:' + String(fmt)); - } - const req: JQuery.AjaxSettings = { - xhr: Api.getAjaxProgressXhrFactory(progress), - url: url + path, - method, - data: formattedData, - dataType: resFmt, - crossDomain: true, - headers, - processData: false, - contentType, - async: true, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - timeout: typeof progress!.upload === 'function' || typeof progress!.download === 'function' ? undefined : 20000, // substituted with {} above - }; - const res = await Api.ajax(req, Catch.stackTrace()); - return res as RT; - }; - private static isRawAjaxErr = (e: unknown): e is RawAjaxErr => { return !!e && typeof e === 'object' && typeof (e as RawAjaxErr).readyState === 'number'; }; @@ -223,4 +445,15 @@ export class Api { throw new Error(`API path traversal forbidden: ${requestUrl}`); } }; + + private static fetchWithRetry = async (url: string, options: RequestInit, attempts = 2): Promise => { + // in firefox fetch sends pre-flight OPTIONS request which sometimes returns CORS error + // on retry fetch sends original request and it passes + try { + return await fetch(url, options); + } catch (err) { + if (err.code !== undefined || attempts <= 1) throw err; + return await Api.fetchWithRetry(url, options, attempts - 1); + } + }; } diff --git a/extension/js/common/browser/browser-msg.ts b/extension/js/common/browser/browser-msg.ts index e304dba29e1..47046be15d6 100644 --- a/extension/js/common/browser/browser-msg.ts +++ b/extension/js/common/browser/browser-msg.ts @@ -3,7 +3,6 @@ 'use strict'; import { AuthRes } from '../api/authentication/google/google-oauth.js'; -import { ApiCallContext } from '../api/shared/api.js'; import { AjaxErr } from '../api/shared/api-error.js'; import { Buf } from '../core/buf.js'; import { Dict, Str, UrlParams } from '../core/common.js'; @@ -17,6 +16,7 @@ import { Browser } from './browser.js'; import { Env } from './env.js'; import { RenderMessage } from '../render-message.js'; import { SymEncryptedMessage, SymmetricMessageEncryption } from '../symmetric-message-encryption.js'; +import { Ajax as ApiAjax, ResFmt } from '../api/shared/api.js'; export type GoogleAuthWindowResult$result = 'Success' | 'Denied' | 'Error' | 'Closed'; @@ -40,6 +40,7 @@ export namespace Bm { to: Dest; data: { bm: AnyRequest & { messageSender?: Dest }; objUrls: Dict }; responseName?: string; + propagateToParent?: boolean; }; export type SetCss = { css: Dict; traverseUp?: number; selector: string }; @@ -81,7 +82,7 @@ export namespace Bm { export type ReconnectAcctAuthPopup = { acctEmail: string; scopes?: string[] }; export type PgpMsgDecrypt = PgpMsgMethod.Arg.Decrypt; export type PgpKeyBinaryToArmored = { binaryKeysData: Uint8Array }; - export type Ajax = { req: JQuery.AjaxSettings; stack: string }; + export type Ajax = { req: ApiAjax; resFmt: ResFmt }; export type AjaxProgress = { operationId: string; percent?: number; loaded: number; total: number; expectedTransferSize: number }; export type AjaxGmailAttachmentGetChunk = { acctEmail: string; msgId: string; attachmentId: string; treatAs: string }; export type ShowAttachmentPreview = { iframeUrl: string }; @@ -136,7 +137,6 @@ export namespace Bm { | ComposeWindowOpenDraft | NotificationShow | PassphraseDialog - | PassphraseDialog | Settings | SetCss | AddOrRemoveClass @@ -145,7 +145,6 @@ export namespace Bm { | InMemoryStoreSet | InMemoryStoreGet | PgpMsgDecrypt - | Ajax | AjaxProgress | ShowAttachmentPreview | ShowConfirmation @@ -155,7 +154,8 @@ export namespace Bm { | RenderMessage | PgpBlockReady | PgpBlockRetry - | ConfirmationResult; + | ConfirmationResult + | Ajax; export type AsyncRespondingHandler = (req: AnyRequest) => Promise; export type AsyncResponselessHandler = (req: AnyRequest) => Promise; @@ -295,13 +295,10 @@ export class BrowserMsg { BrowserMsg.HANDLERS_REGISTERED_FRAME[name] = handler; }; - public static listen = (dest: Bm.Dest[] | string) => { - if (typeof dest === 'string') { - dest = [dest]; - } + public static listen = (dest: Bm.Dest) => { chrome.runtime.onMessage.addListener((msg: Bm.Raw, _sender, rawRespond: (rawResponse: Bm.RawResponse) => void) => { - // console.debug(`listener(${dest}) new message: ${msg.name} to ${msg.to} with id ${msg.uid} from`, sender); - if (msg.to && [...dest, 'broadcast'].includes(msg.to)) { + // console.debug(`listener(${dest}) new message: ${msg.name} to ${msg.to} with id ${msg.uid} from`, _sender); + if (msg.to && [dest, 'broadcast'].includes(msg.to)) { BrowserMsg.handleMsg(msg, rawRespond); return true; } @@ -350,14 +347,14 @@ export class BrowserMsg { }); }; - protected static listenForWindowMessages = (dest: Bm.Dest[]) => { + protected static listenForWindowMessages = (dest: Bm.Dest) => { const extensionOrigin = Env.getExtensionOrigin(); window.addEventListener('message', async e => { if (e.origin !== 'https://mail.google.com' && e.origin !== extensionOrigin) return; const encryptedMsg = e.data as SymEncryptedMessage; if (BrowserMsg.processed.has(encryptedMsg.uid)) return; let handled = false; - if ([...dest, 'broadcast'].includes(encryptedMsg.to)) { + if ([dest, 'broadcast'].includes(encryptedMsg.to)) { const msg = await SymmetricMessageEncryption.decrypt(encryptedMsg); handled = BrowserMsg.handleMsg(msg, (rawResponse: Bm.RawResponse) => { if (msg.responseName && typeof msg.data.bm.messageSender !== 'undefined') { @@ -507,7 +504,15 @@ export class BrowserMsg { }; try { if (chrome.runtime) { - chrome.runtime.sendMessage(msg, processRawMsgResponse); + if (Env.isBackgroundPage()) { + chrome.tabs.query({ active: true, currentWindow: true }, tabs => { + for (const tab of tabs) { + chrome.tabs.sendMessage(Number(tab.id), msg, resolve); + } + }); + } else { + chrome.runtime.sendMessage(msg, processRawMsgResponse); + } } else { BrowserMsg.renderFatalErrCorner('Error: missing chrome.runtime', 'RED-RELOAD-PROMPT'); } diff --git a/extension/js/common/browser/ui.ts b/extension/js/common/browser/ui.ts index 73f648e250a..540521b3448 100644 --- a/extension/js/common/browser/ui.ts +++ b/extension/js/common/browser/ui.ts @@ -268,7 +268,7 @@ export class Ui { } const userResponsePromise = Ui.swal().fire({ didOpen: () => { - Swal.getCloseButton()!.blur(); // eslint-disable-line @typescript-eslint/no-non-null-assertion + Swal.getCloseButton()?.blur(); }, html, width: 750, diff --git a/extension/js/common/core/buf.ts b/extension/js/common/core/buf.ts index 58e6da8bc31..b6df0c3d90b 100644 --- a/extension/js/common/core/buf.ts +++ b/extension/js/common/core/buf.ts @@ -30,11 +30,10 @@ export class Buf extends Uint8Array { return new Buf(u8a); }; - public static fromRawBytesStr = (rawStr: string): Buf => { - const length = rawStr.length; - const buf = new Buf(length); - for (let i = 0; i < length; i++) { - buf[i] = rawStr.charCodeAt(i); + public static fromRawBytesStr = (rawStr: string, start = 0, end = rawStr.length): Buf => { + const buf = new Buf(end - start); + for (let i = 0; i < end - start; i++) { + buf[i] = rawStr.charCodeAt(i + start); } return buf; }; diff --git a/extension/js/common/core/common.ts b/extension/js/common/core/common.ts index 03ac7954414..4cf850f323d 100644 --- a/extension/js/common/core/common.ts +++ b/extension/js/common/core/common.ts @@ -14,6 +14,72 @@ export type EmailParts = { email: string; name?: string }; export const CID_PATTERN = /^cid:(.+)/; +export const HTTP_STATUS_TEXTS: { [code: number]: string } = { + [100]: 'Continue', + [101]: 'Switching Protocols', + [102]: 'Processing', + [103]: 'Early Hints', + [200]: 'OK', + [201]: 'Created', + [202]: 'Accepted', + [203]: 'Non-Authoritative Information', + [204]: 'No Content', + [205]: 'Reset Content', + [206]: 'Partial Content', + [207]: 'Multi-Status', + [208]: 'Already Reported', + [226]: 'IM Used', + [300]: 'Multiple Choices', + [301]: 'Moved Permanently', + [302]: 'Found', + [303]: 'See Other', + [304]: 'Not Modified', + [305]: 'Use Proxy', + [306]: 'Switch Proxy', + [307]: 'Temporary Redirect', + [308]: 'Permanent Redirect', + [400]: 'Bad Request', + [401]: 'Unauthorized', + [402]: 'Payment Required', + [403]: 'Forbidden', + [404]: 'Not Found', + [405]: 'Method Not Allowed', + [406]: 'Not Acceptable', + [407]: 'Proxy Authentication Required', + [408]: 'Request Timeout', + [409]: 'Conflict', + [410]: 'Gone', + [411]: 'Length Required', + [412]: 'Precondition Failed', + [413]: 'Payload Too Large', + [414]: 'URI Too Long', + [415]: 'Unsupported Media Type', + [416]: 'Range Not Satisfiable', + [417]: 'Expectation Failed', + [418]: "I'm a teapot", + [421]: 'Misdirected Request', + [422]: 'Unprocessable Entity', + [423]: 'Locked', + [424]: 'Failed Dependency', + [425]: 'Too Early', + [426]: 'Upgrade Required', + [428]: 'Precondition Required', + [429]: 'Too Many Requests', + [431]: 'Request Header Fields Too Large', + [451]: 'Unavailable For Legal Reasons', + [500]: 'Internal Server Error', + [501]: 'Not Implemented', + [502]: 'Bad Gateway', + [503]: 'Service Unavailable', + [504]: 'Gateway Timeout', + [505]: 'HTTP Version Not Supported', + [506]: 'Variant Also Negotiates', + [507]: 'Insufficient Storage', + [508]: 'Loop Detected', + [510]: 'Not Extended', + [511]: 'Network Authentication Required', +}; + export class Str { // ranges are taken from https://stackoverflow.com/a/14824756 // with the '\u0300' -> '\u0370' modification, because from '\u0300' to '\u0370' there are only punctuation marks @@ -345,11 +411,11 @@ export class Url { return processedParams; }; - public static create = (link: string, params: UrlParams) => { + public static create = (link: string, params: UrlParams, transform = true) => { for (const key of Object.keys(params)) { const value = params[key]; if (typeof value !== 'undefined') { - const transformed = Value.obj.keyByValue(Url.URL_PARAM_DICT, value); + const transformed = transform ? Value.obj.keyByValue(Url.URL_PARAM_DICT, value) : undefined; link += (link.includes('?') ? '&' : '?') + encodeURIComponent(key) + diff --git a/extension/js/common/render-interface.ts b/extension/js/common/render-interface.ts index 77de0ba7b8e..49de8eed2c4 100644 --- a/extension/js/common/render-interface.ts +++ b/extension/js/common/render-interface.ts @@ -2,7 +2,7 @@ 'use strict'; -import { ProgressCb, ProgressDestFrame } from './api/shared/api.js'; +import { ProgressCb } from './api/shared/api.js'; import { TransferableAttachment } from './core/attachment.js'; import { PromiseCancellation } from './core/common.js'; import { PrintMailInfo } from './render-message.js'; @@ -17,7 +17,7 @@ export interface RenderInterfaceBase { export interface RenderInterface extends RenderInterfaceBase { cancellation: PromiseCancellation; - startProgressRendering(text: string): (expectedTransferSize: number) => { download: ProgressCb } & ProgressDestFrame; + startProgressRendering(text: string): (expectedTransferSize: number) => { download: ProgressCb }; renderAsRegularContent(content: string): void; setPrintMailInfo(info: PrintMailInfo): void; clearErrorStatus(): void; diff --git a/extension/js/common/settings.ts b/extension/js/common/settings.ts index 7b5382754ed..a0ea44a2a72 100644 --- a/extension/js/common/settings.ts +++ b/extension/js/common/settings.ts @@ -357,7 +357,7 @@ export class Settings { }); } } else if (response.result === 'Denied' || response.result === 'Closed') { - const authDeniedHtml = (await Api.ajax({ url: '/chrome/settings/modules/auth_denied.htm' }, Catch.stackTrace())) as string; + const authDeniedHtml = await Api.ajax({ url: '/chrome/settings/modules/auth_denied.htm', method: 'GET', stack: Catch.stackTrace() }, 'text'); await Ui.modal.info(`${authDeniedHtml}\n
${Lang.general.contactIfNeedAssistance()}
`, true); } else { // Do not report error for csrf diff --git a/extension/js/common/ui/fetch-key-ui.ts b/extension/js/common/ui/fetch-key-ui.ts index 321541747e7..79b14a83579 100644 --- a/extension/js/common/ui/fetch-key-ui.ts +++ b/extension/js/common/ui/fetch-key-ui.ts @@ -2,10 +2,10 @@ 'use strict'; -import { BrowserMsg } from '../browser/browser-msg.js'; import { Catch } from '../platform/catch.js'; import { KeyImportUi } from './key-import-ui.js'; import { Ui } from '../browser/ui.js'; +import { Api } from '../api/shared/api.js'; export class FetchKeyUI { public handleOnPaste = (elem: JQuery) => { @@ -29,10 +29,7 @@ export class FetchKeyUI { private fetchPubkey = async (url: string) => { try { - const result = (await BrowserMsg.send.bg.await.ajax({ - req: { url, type: 'GET', dataType: 'text', async: true }, - stack: Catch.stackTrace(), - })) as string; + const result: string = await Api.ajax({ url, method: 'GET', stack: Catch.stackTrace() }, 'text'); const keyImportUi = new KeyImportUi({ checkEncryption: true }); return await keyImportUi.checkPub(result); } catch (e) { diff --git a/test/source/mock/all-apis-mock.ts b/test/source/mock/all-apis-mock.ts index acb4e408ef2..b06e95ab4a9 100644 --- a/test/source/mock/all-apis-mock.ts +++ b/test/source/mock/all-apis-mock.ts @@ -3,7 +3,7 @@ 'use strict'; import { Api, Handlers } from './lib/api'; -import * as http from 'http'; +import * as http2 from 'http2'; export type HandlersRequestDefinition = { query: { [k: string]: string }; body?: unknown }; export type HandlersDefinition = Handlers; @@ -12,7 +12,7 @@ export const startAllApisMock = async (logger: (line: string) => void) => { class LoggedApi extends Api { protected throttleChunkMsUpload = 15; protected throttleChunkMsDownload = 200; - protected log = (ms: number, req: http.IncomingMessage, res: http.ServerResponse, errRes?: Buffer) => { + protected log = (ms: number, req: http2.Http2ServerRequest, res: http2.Http2ServerResponse, errRes?: Buffer) => { if (req.url !== '/favicon.ico') { logger(`${ms}ms | ${res.statusCode} ${req.method} ${req.url} | ${errRes ? errRes : ''}`); } diff --git a/test/source/mock/attester/attester-endpoints.ts b/test/source/mock/attester/attester-endpoints.ts index 502554cedbf..16326420bcf 100644 --- a/test/source/mock/attester/attester-endpoints.ts +++ b/test/source/mock/attester/attester-endpoints.ts @@ -24,7 +24,7 @@ export interface AttesterConfig { export const getMockAttesterEndpoints = (oauth: OauthMock, attesterConfig: AttesterConfig): HandlersDefinition => { return { '/attester/pub/?': async ({ body }, req) => { - const emailOrLongid = req.url!.split('/').pop()!.toLowerCase().trim(); + const emailOrLongid = req.url.split('/').pop()!.toLowerCase().trim(); if (isGet(req)) { if (!attesterConfig?.pubkeyLookup) { throw new HttpClientErr('Method not allowed', 405); diff --git a/test/source/mock/fes/customer-url-fes-endpoints.ts b/test/source/mock/fes/customer-url-fes-endpoints.ts index a742637a516..0c8549bdcee 100644 --- a/test/source/mock/fes/customer-url-fes-endpoints.ts +++ b/test/source/mock/fes/customer-url-fes-endpoints.ts @@ -1,10 +1,10 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ import { expect } from 'chai'; -import { IncomingMessage } from 'http'; +import { IncomingHttpHeaders } from 'http'; import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; -import { messageIdRegex, parsePort } from '../lib/mock-util'; +import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; import { MockJwt } from '../lib/oauth'; import { FesConfig } from './shared-tenant-fes-endpoints'; @@ -20,7 +20,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H // standard fes location at https://fes.domain.com '/api/': async ({}, req) => { const port = parsePort(req); - if ([standardFesUrl(port)].includes(req.headers.host || '') && req.method === 'GET') { + if ([standardFesUrl(port)].includes(parseAuthority(req)) && req.method === 'GET') { return { vendor: 'Mock', service: 'external-service', @@ -32,7 +32,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H if (config?.apiEndpointReturnError) { throw config.apiEndpointReturnError; } - throw new HttpClientErr(`Not running any FES here: ${req.headers.host}`); + throw new HttpClientErr(`Not running any FES here: ${parseAuthority(req)}`); }, '/api/v1/client-configuration': async ({}, req) => { // individual ClientConfiguration is tested using FlowCrypt backend mock, see BackendData.getClientConfiguration @@ -44,7 +44,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H clientConfiguration: config.clientConfiguration, }; } - throw new HttpClientErr(`Unexpected FES domain "${req.headers.host}" and url "${req.url}"`); + throw new HttpClientErr(`Unexpected FES domain "${parseAuthority(req)}" and url "${req.url}"`); }, '/api/v1/client-configuration/authentication': async ({}, req) => { if (req.method !== 'GET') { @@ -56,7 +56,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H return {}; }, '/api/v1/message/new-reply-token': async ({}, req) => { - if (req.headers.host === standardFesUrl(parsePort(req)) && req.method === 'POST') { + if (parseAuthority(req) === standardFesUrl(parsePort(req)) && req.method === 'POST') { authenticate(req, 'oidc'); return { replyToken: 'mock-fes-reply-token' }; } @@ -66,7 +66,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H const port = parsePort(req); const fesUrl = standardFesUrl(port); // body is a mime-multipart string, we're doing a few smoke checks here without parsing it - if (req.headers.host === fesUrl && req.method === 'POST' && typeof body === 'string') { + if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') { authenticate(req, 'oidc'); if (config?.messagePostValidator) { return await config.messagePostValidator(body, fesUrl); @@ -77,7 +77,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H }, '/api/v1/message/FES-MOCK-EXTERNAL-ID/gateway': async ({ body }, req) => { const port = parsePort(req); - if (req.headers.host === standardFesUrl(port) && req.method === 'POST') { + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { // test: `compose - user@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES web portal` authenticate(req, 'oidc'); expect(body).to.match(messageIdRegex(port)); @@ -87,7 +87,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H }, '/api/v1/message/FES-MOCK-EXTERNAL-FOR-SENDER@DOMAIN.COM-ID/gateway': async ({ body }, req) => { const port = parsePort(req); - if (req.headers.host === standardFesUrl(port) && req.method === 'POST') { + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { // test: `compose - user2@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES - Reply rendering` authenticate(req, 'oidc'); expect(body).to.match(messageIdRegex(port)); @@ -97,7 +97,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H }, '/api/v1/message/FES-MOCK-EXTERNAL-FOR-TO@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { const port = parsePort(req); - if (req.headers.host === standardFesUrl(port) && req.method === 'POST') { + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { // test: `compose - user@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES web portal` // test: `compose - user2@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES - Reply rendering` // test: `compose - user3@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES web portal - pubkey recipient in bcc` @@ -110,7 +110,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H }, '/api/v1/message/FES-MOCK-EXTERNAL-FOR-BCC@EXAMPLE.COM-ID/gateway': async ({ body }, req) => { const port = parsePort(req); - if (req.headers.host === standardFesUrl(port) && req.method === 'POST') { + if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') { // test: `compose - user@standardsubdomainfes.localhost:8001 - PWD encrypted message with FES web portal` authenticate(req, 'oidc'); expect(body).to.match(messageIdRegex(port)); @@ -125,7 +125,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H }; }; -const authenticate = (req: IncomingMessage, type: 'oidc' | 'fes'): string => { +const authenticate = (req: { headers: IncomingHttpHeaders }, type: 'oidc' | 'fes'): string => { const jwt = (req.headers.authorization || '').replace('Bearer ', ''); if (!jwt) { throw new Error('Mock FES missing authorization header'); diff --git a/test/source/mock/fes/shared-tenant-fes-endpoints.ts b/test/source/mock/fes/shared-tenant-fes-endpoints.ts index 35e7b5cdffa..20283cf2bd0 100644 --- a/test/source/mock/fes/shared-tenant-fes-endpoints.ts +++ b/test/source/mock/fes/shared-tenant-fes-endpoints.ts @@ -1,11 +1,11 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ import { expect } from 'chai'; -import { IncomingMessage } from 'http'; +import { IncomingHttpHeaders } from 'http'; import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api'; import { MockJwt } from '../lib/oauth'; -import { messageIdRegex, parsePort } from '../lib/mock-util'; +import { messageIdRegex, parseAuthority, parsePort } from '../lib/mock-util'; export interface ReportedError { name: string; @@ -92,7 +92,7 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): if (config?.apiEndpointReturnError) { throw config.apiEndpointReturnError; } - throw new HttpClientErr(`Not running any FES here: ${req.headers.host}`); + throw new HttpClientErr(`Not running any FES here: ${parseAuthority(req)}`); }, '/shared-tenant-fes/api/v1/client-configuration': async ({}, req) => { // individual ClientConfiguration is tested using FlowCrypt backend mock, see BackendData.getClientConfiguration @@ -201,7 +201,7 @@ export const getMockSharedTenantFesEndpoints = (config: FesConfig | undefined): }; }; -const authenticate = (req: IncomingMessage, type: 'oidc' | 'fes'): string => { +const authenticate = (req: { headers: IncomingHttpHeaders }, type: 'oidc' | 'fes'): string => { const jwt = (req.headers.authorization || '').replace('Bearer ', ''); if (!jwt) { throw new Error('Mock FES missing authorization header'); @@ -219,4 +219,4 @@ const authenticate = (req: IncomingMessage, type: 'oidc' | 'fes'): string => { return MockJwt.parseEmail(jwt); }; -const messageIdRegexForRequest = (req: IncomingMessage) => messageIdRegex(parsePort(req)); +const messageIdRegexForRequest = (req: { headers: IncomingHttpHeaders }) => messageIdRegex(parsePort(req)); diff --git a/test/source/mock/google/google-endpoints.ts b/test/source/mock/google/google-endpoints.ts index c75e814598d..5ab6f8219d3 100644 --- a/test/source/mock/google/google-endpoints.ts +++ b/test/source/mock/google/google-endpoints.ts @@ -236,7 +236,7 @@ export const getMockGoogleEndpoints = (oauth: OauthMock, config: GoogleConfig | const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req)) { // temporary replacement for parseResourceId() until #5050 is fixed - const id = req.url!.match(/\/([a-zA-Z0-9\-_]+)(\?|$)/)?.[1]; + const id = req.url.match(/\/([a-zA-Z0-9\-_]+)(\?|$)/)?.[1]; if (!id) { return {}; } @@ -282,7 +282,7 @@ export const getMockGoogleEndpoints = (oauth: OauthMock, config: GoogleConfig | } const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req) && (format === 'metadata' || format === 'full')) { - const id = parseResourceId(req.url!); + const id = parseResourceId(req.url); const msgs = (await GoogleData.withInitializedData(acct)).getMessagesAndDraftsByThread(id); if (!msgs.length) { const errorCode = config?.threadNotFoundError?.[id] ?? 400; @@ -337,7 +337,7 @@ export const getMockGoogleEndpoints = (oauth: OauthMock, config: GoogleConfig | '/gmail/v1/users/me/drafts/?': async (parsedReq, req) => { const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req)) { - const id = parseResourceId(req.url!); + const id = parseResourceId(req.url); const data = await GoogleData.withInitializedData(acct); const draft = data.getDraft(id); if (draft) { @@ -363,7 +363,7 @@ export const getMockGoogleEndpoints = (oauth: OauthMock, config: GoogleConfig | '/gmail/?': async ({}, req) => { const acct = oauth.checkAuthorizationHeaderWithAccessToken(req.headers.authorization); if (isGet(req)) { - const id = parseResourceId(req.url!); + const id = parseResourceId(req.url); return await GoogleData.getMockGmailPage(acct, id, config?.htmlRenderer); } throw new HttpClientErr(`Method not implemented for ${req.url}: ${req.method}`); diff --git a/test/source/mock/keys-openpgp-org/keys-openpgp-org-endpoints.ts b/test/source/mock/keys-openpgp-org/keys-openpgp-org-endpoints.ts index 3a9bc7bea7f..7ef231dfb98 100644 --- a/test/source/mock/keys-openpgp-org/keys-openpgp-org-endpoints.ts +++ b/test/source/mock/keys-openpgp-org/keys-openpgp-org-endpoints.ts @@ -9,7 +9,7 @@ export type KeysOpenPGPOrgConfig = Record; export const getMockKeysOpenPGPOrgEndpoints = (keysOpenPGPOrgConfig: KeysOpenPGPOrgConfig | undefined): HandlersDefinition => { return { '/keys-openpgp-org/vks/v1/by-email/?': async ({}, req) => { - const email = decodeURIComponent(req.url!.split('/').pop()!.toLowerCase().trim()); + const email = decodeURIComponent(req.url.split('/').pop()!.toLowerCase().trim()); if (!isGet(req)) { throw new HttpClientErr(`Not implemented: ${req.method}`); } diff --git a/test/source/mock/lib/api.ts b/test/source/mock/lib/api.ts index 7dc8d9a1312..6fdf8ba249d 100644 --- a/test/source/mock/lib/api.ts +++ b/test/source/mock/lib/api.ts @@ -1,7 +1,6 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ -import * as https from 'https'; -import * as http from 'http'; +import * as http2 from 'http2'; import { Util } from '../../util'; import { readFileSync } from 'fs'; import { AttesterConfig, getMockAttesterEndpoints } from '../attester/attester-endpoints'; @@ -40,7 +39,7 @@ export enum Status { } /* eslint-enable @typescript-eslint/naming-convention */ -export type RequestHandler = (parsedReqBody: REQ, req: http.IncomingMessage) => Promise; +export type RequestHandler = (parsedReqBody: REQ, req: http2.Http2ServerRequest) => Promise; export type Handlers = { [request: string]: RequestHandler }; interface ConfigurationOptions { @@ -83,7 +82,7 @@ export class ConfigurationProvider implements ConfigurationProviderInterface { - public server: https.Server; + public server: http2.Http2Server; public configProvider: ConfigurationProviderInterface | undefined; protected apiName: string; protected maxRequestSizeMb = 0; @@ -101,7 +100,7 @@ export class Api { key: readFileSync(`./test/mock_cert/key.pem.mock`), cert: readFileSync(`./test/mock_cert/cert.pem.mock`), }; - this.server = https.createServer(opt, (request, response) => { + this.server = http2.createSecureServer(opt, (request, response) => { const start = Date.now(); this.handleReq(request, response) .then(data => this.throttledResponse(response, data)) @@ -172,12 +171,12 @@ export class Api { }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected log = (ms: number, req: http.IncomingMessage, res: http.ServerResponse, errRes?: Buffer) => { + protected log = (ms: number, req: http2.Http2ServerRequest, res: http2.Http2ServerResponse, errRes?: Buffer) => { // eslint-disable-next-line @typescript-eslint/no-invalid-void-type return undefined as void; }; - protected handleReq = async (req: http.IncomingMessage, res: http.ServerResponse): Promise => { + protected handleReq = async (req: http2.Http2ServerRequest, res: http2.Http2ServerResponse): Promise => { if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Headers', '*'); @@ -199,10 +198,7 @@ export class Api { throw new HttpClientErr(`unknown MOCK path ${req.url}`); }; - protected chooseHandler = (req: http.IncomingMessage): RequestHandler | undefined => { - if (!req.url) { - throw new Error('no url'); - } + protected chooseHandler = (req: { url: string }): RequestHandler | undefined => { const configHandlers = this.configProvider?.getHandlers() ?? {}; const allHandlers: Handlers = { ...configHandlers, @@ -236,7 +232,7 @@ export class Api { ); }; - protected fmtHandlerRes = (handlerRes: RES, serverRes: http.ServerResponse): Buffer => { + protected fmtHandlerRes = (handlerRes: RES, serverRes: http2.Http2ServerResponse): Buffer => { if (typeof handlerRes === 'string' && handlerRes.match(/^/)) { serverRes.setHeader('content-type', 'text/html'); } else if (typeof handlerRes === 'object' || (typeof handlerRes === 'string' && handlerRes.match(/^\{/) && handlerRes.match(/\}$/))) { @@ -260,7 +256,7 @@ export class Api { return Buffer.from(json); }; - protected collectReq = (req: http.IncomingMessage): Promise => { + protected collectReq = (req: http2.Http2ServerRequest): Promise => { return new Promise((resolve, reject) => { const body: Buffer[] = []; let byteLength = 0; @@ -286,25 +282,24 @@ export class Api { }); }; - protected parseReqBody = (body: Buffer, req: http.IncomingMessage): REQ => { + protected parseReqBody = (body: Buffer, req: http2.Http2ServerRequest): REQ => { let parsedBody: string | undefined; - if (body.length) { if ( - req.url!.startsWith('/upload/') || // gmail message send - (req.url!.startsWith('/attester/pub/') && req.method === 'POST') || // attester submit - req.url!.startsWith('/api/v1/message') || // FES pwd msg - req.url!.startsWith('/shared-tenant-fes/api/v1/message') // Shared TENANT FES pwd msg + req.url.startsWith('/upload/') || // gmail message send + (req.url.startsWith('/attester/pub/') && req.method === 'POST') || // attester submit + req.url.startsWith('/api/v1/message') || // FES pwd msg + req.url.startsWith('/shared-tenant-fes/api/v1/message') // Shared TENANT FES pwd msg ) { parsedBody = body.toString(); } else { parsedBody = JSON.parse(body.toString()); } } - return { query: this.parseUrlQuery(req.url!), body: parsedBody } as unknown as REQ; + return { query: this.parseUrlQuery(req.url), body: parsedBody } as unknown as REQ; }; - private throttledResponse = async (response: http.ServerResponse, data: Buffer) => { + private throttledResponse = async (response: http2.Http2ServerResponse, data: Buffer) => { // If google oauth2 login, then redirect to url if (/^https:\/\/google\.localhost:[0-9]+\/robots\.txt/.test(data.toString())) { response.writeHead(302, { Location: data.toString() }); // eslint-disable-line @typescript-eslint/naming-convention diff --git a/test/source/mock/lib/mock-util.ts b/test/source/mock/lib/mock-util.ts index 44ff958859a..5d4f654fc39 100644 --- a/test/source/mock/lib/mock-util.ts +++ b/test/source/mock/lib/mock-util.ts @@ -1,13 +1,16 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ -import { IncomingMessage } from 'http'; - -export const isGet = (r: IncomingMessage) => r.method === 'GET' || r.method === 'HEAD'; -export const isPost = (r: IncomingMessage) => r.method === 'POST'; -export const isPut = (r: IncomingMessage) => r.method === 'PUT'; -export const isDelete = (r: IncomingMessage) => r.method === 'DELETE'; - -export const parsePort = (r: IncomingMessage) => r.headers.host!.split(':')[1]; +import { IncomingHttpHeaders } from 'http2'; +export const isGet = (r: { method: string }) => r.method === 'GET' || r.method === 'HEAD'; +export const isPost = (r: { method: string }) => r.method === 'POST'; +export const isPut = (r: { method: string }) => r.method === 'PUT'; +export const isDelete = (r: { method: string }) => r.method === 'DELETE'; +export const parseAuthority = (r: { headers: IncomingHttpHeaders }) => { + return r.headers.host ?? r.headers[':authority'] ?? ''; +}; +export const parsePort = (r: { headers: IncomingHttpHeaders }) => { + return parseAuthority(r).split(':')[1]; +}; export const parseResourceId = (url: string) => url.match(/\/([a-zA-Z0-9\-_]+)(\?|$)/)![1]; export const messageIdRegex = (port: string) => new RegExp(`{"emailGatewayMessageId":"<(.+)@standardsubdomainfes.localhost:${port}>"}`); diff --git a/test/source/mock/lib/oauth.ts b/test/source/mock/lib/oauth.ts index 37821a5ab03..418744c924c 100644 --- a/test/source/mock/lib/oauth.ts +++ b/test/source/mock/lib/oauth.ts @@ -92,7 +92,13 @@ export class OauthMock { throw new HttpClientErr('Missing mock bearer authorization header', Status.UNAUTHORIZED); } const accessToken = authorization.replace(/^Bearer /, ''); - const acct = this.acctByIdToken[accessToken]; + let acct = this.acctByIdToken[accessToken]; + if (!acct) { + // After logging in to a mock google account, the browser submits google's access token to some other endpoints + // Specifying an idtoken in `Authorization` header doesn't help, it gets overriden with the access token by the browser + // todo: Inspect how we should solve this + acct = this.acctByAccessToken[accessToken]; + } if (!acct) { throw new HttpClientErr('Invalid idToken token', Status.UNAUTHORIZED); } diff --git a/test/source/mock/wkd/wkd-endpoints.ts b/test/source/mock/wkd/wkd-endpoints.ts index d562864b1b6..2f8fc9641b0 100644 --- a/test/source/mock/wkd/wkd-endpoints.ts +++ b/test/source/mock/wkd/wkd-endpoints.ts @@ -4,7 +4,7 @@ import { PgpArmor } from '../../core/crypto/pgp/pgp-armor'; import { HandlersDefinition } from '../all-apis-mock'; import { HttpClientErr, Status } from '../lib/api.js'; import { isGet } from '../lib/mock-util.js'; -import { IncomingMessage } from 'http'; +import { Http2ServerRequest } from 'http2'; // todo - add a not found test with: throw new HttpClientErr('Pubkey not found', 404); @@ -18,8 +18,8 @@ export interface WkdConfig { advancedLookup?: Record; } -const fetchKeyResult = async (req: IncomingMessage, keyRecord: Record | undefined) => { - const emailLocalPart = req.url!.split('?l=').pop()!.toLowerCase().trim(); +const fetchKeyResult = async (req: Http2ServerRequest, keyRecord: Record | undefined) => { + const emailLocalPart = req.url.split('?l=').pop()!.toLowerCase().trim(); if (!keyRecord) { return ''; } diff --git a/test/source/patterns.ts b/test/source/patterns.ts index 3f63accf814..89eb38772dc 100644 --- a/test/source/patterns.ts +++ b/test/source/patterns.ts @@ -30,7 +30,7 @@ const hasXssComment = (line: string) => { }; const hasErrHandledComment = (line: string) => { - return /\/\/ error-handled/.test(line); + return /\/[\/\*] error-handled/.test(line); }; const validateTypeScriptLine = (line: string, location: string) => { diff --git a/test/source/test.ts b/test/source/test.ts index 538af018d75..d29254f556c 100644 --- a/test/source/test.ts +++ b/test/source/test.ts @@ -150,15 +150,16 @@ test.after.always('evaluate Catch.reportErr errors', async t => { const usefulErrors = reportedErrors .filter(e => e.message !== 'Too few bytes to read ASN.1 value.') // below for test "get.updating.key@key-manager-choose-passphrase-forbid-storing.flowcrypt.test - automatic update of key found on key manager" + // and for test "setup [using key manager] - notify users when their keys expire soon" .filter( e => e.message !== 'Some keys could not be parsed' && - !e.message.match(/BrowserMsg\(ajax\) Bad Request: 400 when GET-ing https:\/\/localhost:\d+\/flowcrypt-email-key-manager/) + !e.message.match(/Bad Request: 400 when GET-ing https:\/\/.*localhost:\d+\/flowcrypt-email-key-manager/) ) // below for test "decrypt - failure retrieving chunk download - next request will try anew" .filter( e => - !/BrowserMsg\(ajaxGmailAttachmentGetChunk\) \(no status text\): 400 when GET-ing https:\/\/localhost:\d+\/gmail\/v1\/users\/me\/messages\/1885ded59a2b5a8d\/attachments\/ANGjdJ_0g7PGqJSjI8-Wjd5o8HcVnAHxIk-H210TAxxwf/.test( + !/400 when GET-ing https:\/\/.*localhost:\d+\/gmail\/v1\/users\/me\/messages\/1885ded59a2b5a8d\/attachments\/ANGjdJ_0g7PGqJSjI8-Wjd5o8HcVnAHxIk-H210TAxxwf/.test( e.message ) ) diff --git a/test/source/tests/gmail.ts b/test/source/tests/gmail.ts index 4b0db66e9ef..33697856dee 100644 --- a/test/source/tests/gmail.ts +++ b/test/source/tests/gmail.ts @@ -172,8 +172,8 @@ export const defineGmailTests = (testVariant: TestVariant, testWithBrowser: Test testWithBrowser(async (t, browser) => { await BrowserRecipe.setUpCommonAcct(t, browser, 'ci.tests.gmail'); const gmailPage = await openGmailPage(t, browser); - expect(await gmailPage.isElementPresent('@action-show-original-conversation')).to.equal(true); await gotoGmailPage(gmailPage, '/QgrcJHrtqfgLGKqwChjKsHKzZQpwRHMBqpG'); + await gmailPage.waitAll('@action-show-original-conversation'); const urls = await gmailPage.getFramesUrls(['/chrome/elements/pgp_block.htm'], { sleep: 10, appearIn: 25 }); expect(urls.length).to.equal(1); const pgpBlockFrame = await gmailPage.getFrame(['pgp_block.htm']); @@ -317,8 +317,7 @@ export const defineGmailTests = (testVariant: TestVariant, testWithBrowser: Test await gmailPage.reload({ timeout: TIMEOUT_PAGE_LOAD * 1000, waitUntil: 'load' }, true); replyBox = await pageHasSecureDraft(gmailPage, 'offline reply draft'); await Util.sleep(2); - await replyBox.waitAndClick('@action-send'); - await replyBox.waitTillGone('@action-send'); + await replyBox.waitAndClick('@action-send', { confirmGone: true }); await Util.sleep(2); await gmailPage.reload({ timeout: TIMEOUT_PAGE_LOAD * 1000, waitUntil: 'load' }, true); await gmailPage.waitAndClick('.h7:last-child .ajz', { delay: 1 }); // the small triangle which toggles the message details @@ -604,8 +603,10 @@ export const defineGmailTests = (testVariant: TestVariant, testWithBrowser: Test await BrowserRecipe.setUpCommonAcct(t, browser, 'ci.tests.gmail'); const composePage = await ComposePageRecipe.openStandalone(t, browser, acctEmail); await ComposePageRecipe.fillMsg(composePage, { to: 'demo@flowcrypt.com' }, 'should find pubkey from WKD directly'); - await composePage.waitForContent('.email_address.has_pgp', 'demo@flowcrypt.com'); - expect(await composePage.attr('.email_address.has_pgp', 'title')).to.contain('0997 7F6F 512C A5AD 76F0 C210 248B 60EB 6D04 4DF8 (openpgp)'); + // TODO: demo@flowcrypt.com key should be updated + // await composePage.waitForContent('.email_address.has_pgp', 'demo@flowcrypt.com'); + await composePage.waitForContent('.email_address.expired', 'demo@flowcrypt.com'); + expect(await composePage.attr('.email_address.expired', 'title')).to.contain('0997 7F6F 512C A5AD 76F0 C210 248B 60EB 6D04 4DF8 (openpgp)'); }) ); diff --git a/test/source/tests/page-recipe/gmail-page-recipe.ts b/test/source/tests/page-recipe/gmail-page-recipe.ts index 39e98ee8736..0bd403a61a8 100644 --- a/test/source/tests/page-recipe/gmail-page-recipe.ts +++ b/test/source/tests/page-recipe/gmail-page-recipe.ts @@ -53,7 +53,7 @@ export class GmailPageRecipe extends PageRecipe { const moreActionsButton = await lastMessageElement.$$('[aria-label="More"]'); expect(moreActionsButton.length).to.equal(1); await moreActionsButton[0].click(); - await gmailPage.press('ArrowDown', 5); + await gmailPage.press('ArrowDown', 4); await gmailPage.press('Enter'); await Util.sleep(3); await gmailPage.page.reload({ timeout: TIMEOUT_PAGE_LOAD * 1000, waitUntil: 'networkidle2' });