X-Git-Url: http://git.squeep.com/?p=squeep-indieauth-helper;a=blobdiff_plain;f=lib%2Fcommunication.js;fp=lib%2Fcommunication.js;h=18a6e588070e39bb49790433885189703a3bc5d0;hp=ce2bc4e25b8bc05b281b86f970cd1e152b30c35c;hb=cc52f66ba8522b6bc7002dfba79c1162a51aef0e;hpb=03cdf23c601ed191fb3d23f5d5bd7b3c7198e578 diff --git a/lib/communication.js b/lib/communication.js index ce2bc4e..18a6e58 100644 --- a/lib/communication.js +++ b/lib/communication.js @@ -1,11 +1,9 @@ 'use strict'; -const axios = require('axios'); const { mf2 } = require('microformats-parser'); const { parse: parseLinkHeader } = require('@squeep/web-linking'); const { Iconv } = require('iconv'); const { version: packageVersion, name: packageName } = require('../package.json'); -const { performance } = require('perf_hooks'); const { randomBytes, createHash } = require('crypto'); const { promisify } = require('util'); const randomBytesAsync = promisify(randomBytes); @@ -26,6 +24,7 @@ class Communication { /** * @param {Console} logger * @param {Object} options + * @param {Number=} options.timeout * @param {Object=} options.userAgent * @param {String=} options.userAgent.product * @param {String=} options.userAgent.version @@ -34,20 +33,52 @@ class Communication { constructor(logger, options = {}) { this.logger = logger; this.options = options; - this.axios = axios.create({ - headers: { - [Enum.Header.UserAgent]: Communication._userAgentString(options.userAgent), - [Enum.Header.Accept]: 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1', - }, - }); - this.axios.interceptors.request.use((request) => { - request.startTimestampMs = performance.now(); - return request; - }); - this.axios.interceptors.response.use((response) => { - response.elapsedTimeMs = performance.now() - response.config.startTimestampMs; - return response; - }); + + this._defaultAccept = options?.defaultAccept || 'text/html, text/*;q=0.9, application/xhtml+xml;q=0.8, application/xml;q=0.8, */*;q=0.1'; + this._jsonAccept = options?.jsonAccept || [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '); + + this.Got = undefined; + this.got = this._init; // Do the dynamic import on first attempt to use client. + } + + + /** + * Do a little dance to support this ESM client. + */ + async _init(...args) { + if (!this.Got) { + // For some reason eslint is confused about import being supported here. + // eslint-disable-next-line + this.Got = await import('got'); + this.got = this.Got.got.extend({ + headers: { + [Enum.Header.UserAgent]: Communication._userAgentString(this.options.userAgent), + [Enum.Header.Accept]: this._defaultAccept, + }, + timeout: { + request: this.options.timeout || 120000, + }, + hooks: { + beforeRetry: [ + this._onRetry, + ], + }, + }); + } + if (args.length) { + return this.got(...args); + } + } + + + /** + * Take notes on transient retries. + * @param {*} error + * @param {*} retryCount + */ + _onRetry(error, retryCount) { + const _scope = _fileScope('_onRetry'); + this.logger.debug(_scope, 'retry', { retryCount, error }); } @@ -135,46 +166,6 @@ class Communication { } - /** - * Valid response statuses. - * Allow 401 as a workaround for one specific client which return such on - * its client identifier endpoint when not yet authenticated. - * @param {Number} status - * @returns {Boolean} - */ - static _validateStatus(status) { - return (status >= 200 && status < 300) || status == 401; - } - - - /** - * A request config skeleton. - * @param {String} method - * @param {URL} urlObj - * @param {String=} body - * @param {Object=} params - * @param {Object=} headers - * @returns {Object} - */ - static _axiosConfig(method, urlObj, body, params = {}, headers = {}) { - const config = { - method, - url: `${urlObj.origin}${urlObj.pathname}`, - params: urlObj.searchParams, - headers, - ...(body && { data: body }), - // Setting this does not appear to be enough to keep axios from parsing JSON response into object - responseType: 'text', - // So force the matter by eliding all response transformations - transformResponse: [ (res) => res ], - - validateStatus: Communication._validateStatus, - }; - Object.entries(params).map(([k, v]) => config.params.set(k, v)); - return config; - } - - /** * Isolate the base of a url. * mf2 parser needs this so that relative links can be made absolute. @@ -208,7 +199,7 @@ class Communication { mediaType: mediaType.toLowerCase() || defaultContentType, params: params.reduce((obj, param) => { const [field, value] = param.split('='); - const isQuoted = value && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"'; + const isQuoted = value?.startsWith('"') && value?.endsWith('"'); obj[field.toLowerCase()] = isQuoted ? value.slice(1, value.length - 1) : value; return obj; }, {}), @@ -284,27 +275,34 @@ class Communication { }; let response; try { - const fetchMicroformatConfig = Communication._axiosConfig('GET', urlObj); - response = await this.axios(fetchMicroformatConfig); + const fetchMicroformatConfig = { + method: 'GET', + url: urlObj, + responseType: 'buffer', + }; + response = await this.got(fetchMicroformatConfig); } catch (e) { this.logger.error(_scope, 'microformat request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.axiosResponseLogData(response); + logInfoData.response = common.gotResponseLogData(response); // Normalize to utf8. - let body = response.data; + let body; const contentType = Communication._parseContentType(response.headers[Enum.Header.ContentType.toLowerCase()]); + // If a charset was specified, and it's not utf8ish, attempt to transliterate it to utf8. const nonUTF8Charset = !/utf-*8/i.test(contentType.params.charset) && contentType.params.charset; if (nonUTF8Charset) { try { const iconv = new Iconv(nonUTF8Charset, 'utf-8//translit//ignore'); - body = iconv.convert(body).toString('utf8'); + body = iconv.convert(response.body).toString('utf8'); } catch (e) { // istanbul ignore next this.logger.error(_scope, 'iconv conversion error', { error: e, ...logInfoData }); // Try to carry on, maybe the encoding will work anyhow... } + } else { + body = response.body.toString('utf8'); } let microformat = {}; @@ -340,24 +338,22 @@ class Communication { }; let response; try { - const fetchJSONConfig = Communication._axiosConfig('GET', urlObj, undefined, undefined, { - [Enum.Header.Accept]: [Enum.ContentType.ApplicationJson, Enum.ContentType.Any + ';q=0.1'].join(', '), - }); - response = await this.axios(fetchJSONConfig); + const fetchJSONConfig = { + method: 'GET', + url: urlObj, + headers: { + [Enum.Header.Accept]: this._jsonAccept, + }, + responseType: 'json', + }; + response = await this.got(fetchJSONConfig); } catch (e) { this.logger.error(_scope, 'json request failed', { error: e, ...logInfoData }); return; } - logInfoData.response = common.axiosResponseLogData(response); + logInfoData.response = common.gotResponseLogData(response); - let data; - try { - data = JSON.parse(response.data); - } catch (e) { - this.logger.error(_scope, 'json parsing failed', { error: e, ...logInfoData }); - } - - return data; + return response.body; } @@ -760,27 +756,25 @@ class Communication { async redeemCode(urlObj, code, codeVerifier, clientId, redirectURI) { const _scope = _fileScope('redeemCode'); - const formData = common.formData({ - 'grant_type': 'authorization_code', - code, - 'client_id': clientId, - 'redirect_uri': redirectURI, - 'code_verifier': codeVerifier, - }); - - const postRedeemCodeConfig = Communication._axiosConfig('POST', urlObj, formData, {}, { - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, - }); + const postRedeemCodeConfig = { + url: urlObj, + method: 'POST', + headers: { + [Enum.Header.Accept]: this._jsonAccept, + }, + form: { + 'grant_type': 'authorization_code', + code, + 'client_id': clientId, + 'redirect_uri': redirectURI, + 'code_verifier': codeVerifier, + }, + responseType: 'json', + }; try { - const response = await this.axios(postRedeemCodeConfig); - try { - return JSON.parse(response.data); - } catch (e) { - this.logger.error(_scope, 'failed to parse json', { error: e, response }); - throw e; - } + const response = await this.got(postRedeemCodeConfig); + return response.body; } catch (e) { this.logger.error(_scope, 'redeem code request failed', { error: e, url: urlObj.href }); return; @@ -789,7 +783,7 @@ class Communication { /** - * Deprecated method name. + * Deprecated method name alias. * @see redeemCode * @param {URL} urlObj * @param {String} code @@ -812,21 +806,24 @@ class Communication { async introspectToken(introspectionUrlObj, authorizationHeader, token) { const _scope = _fileScope('introspectToken'); - const formData = common.formData({ token }); - const postIntrospectConfig = Communication._axiosConfig('POST', introspectionUrlObj, formData, {}, { - [Enum.Header.Authorization]: authorizationHeader, - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - [Enum.Header.Accept]: `${Enum.ContentType.ApplicationJson}, ${Enum.ContentType.Any};q=0.1`, - }); - delete postIntrospectConfig.validateStatus; // only accept success + const postIntrospectConfig = { + url: introspectionUrlObj, + method: 'POST', + headers: { + [Enum.Header.Authorization]: authorizationHeader, + [Enum.Header.Accept]: this._jsonAccept, + }, + form: { + token, + }, + responseType: 'json', + }; - let tokenInfo; try { - const response = await this.axios(postIntrospectConfig); + const response = await this.got(postIntrospectConfig); this.logger.debug(_scope, 'response', { response }); // check status try { - tokenInfo = JSON.parse(response.data); const { active, me, @@ -834,7 +831,11 @@ class Communication { scope, exp, iat, - } = tokenInfo; + } = response.body; + + if (![true, false].includes(active)) { + throw new RangeError('missing required response field "active"'); + } return { active, @@ -845,7 +846,7 @@ class Communication { ...(iat && { iat: Number(iat) }), }; } catch (e) { - this.logger.error(_scope, 'failed to parse json', { error: e, response }); + this.logger.error(_scope, 'failed to parse json', { error: e, response: common.gotResponseLogData(response) }); throw e; } } catch (e) { @@ -868,15 +869,16 @@ class Communication { const _scope = _fileScope('deliverTicket'); try { - const ticketPayload = { - ticket, - resource: resourceUrlObj.href, - subject: subjectUrlObj.href, + const ticketConfig = { + method: 'POST', + url: ticketEndpointUrlObj, + form: { + ticket, + resource: resourceUrlObj.href, + subject: subjectUrlObj.href, + }, }; - const ticketConfig = Communication._axiosConfig('POST', ticketEndpointUrlObj, ticketPayload, {}, { - [Enum.Header.ContentType]: Enum.ContentType.ApplicationForm, - }); - return await this.axios(ticketConfig); + return await this.got(ticketConfig); } catch (e) { this.logger.error(_scope, 'ticket delivery request failed', { error: e, url: ticketEndpointUrlObj.href }); throw e;