/** * Implementation of the WHATWG URL Standard. * * @example * const urlEncoder = require('postman-url-encoder') * * // Encoding URL string to Node.js compatible Url object * urlEncoder.toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`') * * // Encoding URI component * urlEncoder.encode('qüêry štrìng') * * // Encoding query string object * urlEncoder.encodeQueryString({ q1: 'foo', q2: ['bãr', 'baž'] }) * * @module postman-url-encoder * @see {@link https://url.spec.whatwg.org} */ const querystring = require('querystring'), legacy = require('./legacy'), parser = require('./parser'), encoder = require('./encoder'), QUERY_ENCODE_SET = require('./encoder/encode-set').QUERY_ENCODE_SET, E = '', COLON = ':', BACK_SLASH = '\\', DOUBLE_SLASH = '//', DOUBLE_BACK_SLASH = '\\\\', STRING = 'string', OBJECT = 'object', FUNCTION = 'function', DEFAULT_PROTOCOL = 'http', LEFT_SQUARE_BRACKET = '[', RIGHT_SQUARE_BRACKET = ']', PATH_SEPARATOR = '/', QUERY_SEPARATOR = '?', PARAMS_SEPARATOR = '&', SEARCH_SEPARATOR = '#', DOMAIN_SEPARATOR = '.', AUTH_CREDENTIALS_SEPARATOR = '@', // @note this regular expression is referred from Node.js URL parser PROTOCOL_RE = /^[a-z0-9.+-]+:(?:\/\/|\\\\)./i, /** * Protocols that always contain a // bit. * * @private * @see {@link https://github.com/nodejs/node/blob/v10.17.0/lib/url.js#L91} */ SLASHED_PROTOCOLS = { 'file:': true, 'ftp:': true, 'gopher:': true, 'http:': true, 'https:': true, 'ws:': true, 'wss:': true }; /** * Returns stringified URL from Url object but only includes parts till given * part name. * * @example * var url = 'http://postman.com/foo?q=v#hash'; * getUrlTill(toNodeUrl(url), 'host') * // returns 'http://postman.com' * * @private * @param {Object} url base URL * @param {String} [urlPart='query'] one of ['host', 'pathname', 'query'] */ function getUrlTill (url, urlPart) { let result = ''; if (url.protocol) { result += url.protocol + DOUBLE_SLASH; } if (url.auth) { result += url.auth + AUTH_CREDENTIALS_SEPARATOR; } result += url.host || E; if (urlPart === 'host') { return result; } result += url.pathname; if (urlPart === 'pathname') { return result; } // urlPart must be query at this point return result + (url.search || E); } /** * Percent-encode the given string using QUERY_ENCODE_SET. * * @deprecated since version 2.0, use {@link encodeQueryParam} instead. * * @example * // returns 'foo%20%22%23%26%27%3C%3D%3E%20bar' * encode('foo "#&\'<=> bar') * * // returns '' * encode(['foobar']) * * @param {String} value String to percent-encode * @returns {String} Percent-encoded string */ function encode (value) { return encoder.percentEncode(value, QUERY_ENCODE_SET); } /** * Percent-encode the URL query string or x-www-form-urlencoded body object * according to RFC3986. * * @example * // returns 'q1=foo&q2=bar&q2=baz' * encodeQueryString({ q1: 'foo', q2: ['bar', 'baz'] }) * * @param {Object} query Object representing query or urlencoded body * @returns {String} Percent-encoded string */ function encodeQueryString (query) { if (!(query && typeof query === OBJECT)) { return E; } // rely upon faster querystring module query = querystring.stringify(query); // encode characters not encoded by querystring.stringify() according to RFC3986. return query.replace(/[!'()*]/g, function (c) { return encoder.percentEncodeCharCode(c.charCodeAt(0)); }); } /** * Converts PostmanUrl / URL string into Node.js compatible Url object. * * @example Using URL string * toNodeUrl('郵便屋さん.com/foo&bar/{baz}?q=("foo")#`hash`') * // returns * // { * // protocol: 'http:', * // slashes: true, * // auth: null, * // host: 'xn--48jwgn17gdel797d.com', * // port: null, * // hostname: 'xn--48jwgn17gdel797d.com', * // hash: '#%60hash%60', * // search: '?q=(%22foo%22)', * // query: 'q=(%22foo%22)', * // pathname: '/foo&bar/%7Bbaz%7D', * // path: '/foo&bar/%7Bbaz%7D?q=(%22foo%22)', * // href: 'http://xn--48jwgn17gdel797d.com/foo&bar/%7Bbaz%7D?q=(%22foo%22)#%60hash%60' * // } * * @example Using PostmanUrl instance * toNodeUrl(new sdk.Url({ * host: 'example.com', * query: [{ key: 'foo', value: 'bar & baz' }] * })) * * @param {PostmanUrl|String} url URL string or PostmanUrl object * @param {Boolean} disableEncoding Turn encoding off * @returns {Url} Node.js like parsed and encoded object */ function toNodeUrl (url, disableEncoding) { let nodeUrl = { protocol: null, slashes: null, auth: null, host: null, port: null, hostname: null, hash: null, search: null, query: null, pathname: null, path: null, href: E }, port, hostname, pathname, authUser, queryParams, authPassword; // Check if PostmanUrl instance and prepare segments if (url && url.constructor && url.constructor._postman_propertyName === 'Url') { // @note getPath() always adds a leading '/', similar to Node.js API pathname = url.getPath(); hostname = url.getHost().toLowerCase(); if (url.query && url.query.count()) { queryParams = url.getQueryString({ ignoreDisabled: true }); queryParams = disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams); // either all the params are disabled or a single param is like { key: '' } (http://localhost?) // in that case, query separator ? must be included in the raw URL. // @todo Add helper in SDK to handle this if (queryParams === E) { // check if there's any enabled param, if so, set queryString to empty string // otherwise (all disabled), it will be set as undefined queryParams = url.query.find(function (param) { return !(param && param.disabled); }) && E; } } if (url.auth) { authUser = url.auth.user; authPassword = url.auth.password; } } // Parser URL string and prepare segments else if (typeof url === STRING) { url = parser.parse(url); pathname = PATH_SEPARATOR + (url.path || []).join(PATH_SEPARATOR); hostname = (url.host || []).join(DOMAIN_SEPARATOR).toLowerCase(); queryParams = url.query && (queryParams = url.query.join(PARAMS_SEPARATOR)) && (disableEncoding ? queryParams : encoder.encodeQueryParam(queryParams)); authUser = (url.auth || [])[0]; authPassword = (url.auth || [])[1]; } // bail out with empty URL object for invalid input else { return nodeUrl; } // @todo Add helper in SDK to normalize port // eslint-disable-next-line no-eq-null, eqeqeq if (!(url.port == null) && typeof url.port.toString === FUNCTION) { port = url.port.toString(); } // #protocol nodeUrl.protocol = (typeof url.protocol === STRING) ? url.protocol.toLowerCase() : DEFAULT_PROTOCOL; nodeUrl.protocol += COLON; // #slashes nodeUrl.slashes = SLASHED_PROTOCOLS[nodeUrl.protocol] || false; // #href = protocol:// nodeUrl.href = nodeUrl.protocol + DOUBLE_SLASH; // #auth if (url.auth) { if (typeof authUser === STRING) { nodeUrl.auth = disableEncoding ? authUser : encoder.encodeUserInfo(authUser); } if (typeof authPassword === STRING) { !nodeUrl.auth && (nodeUrl.auth = E); nodeUrl.auth += COLON + (disableEncoding ? authPassword : encoder.encodeUserInfo(authPassword)); } if (typeof nodeUrl.auth === STRING) { // #href = protocol://user:password@ nodeUrl.href += nodeUrl.auth + AUTH_CREDENTIALS_SEPARATOR; } } // #host, #hostname nodeUrl.host = nodeUrl.hostname = hostname = encoder.encodeHost(hostname); // @note always encode hostname // #href = protocol://user:password@host.name nodeUrl.href += nodeUrl.hostname; // #port if (typeof port === STRING) { nodeUrl.port = port; // #host = (#hostname):(#port) nodeUrl.host = nodeUrl.hostname + COLON + port; // #href = protocol://user:password@host.name:port nodeUrl.href += COLON + port; } // #path, #pathname nodeUrl.path = nodeUrl.pathname = disableEncoding ? pathname : encoder.encodePath(pathname); // #href = protocol://user:password@host.name:port/p/a/t/h nodeUrl.href += nodeUrl.pathname; if (typeof queryParams === STRING) { // #query nodeUrl.query = queryParams; // #search nodeUrl.search = QUERY_SEPARATOR + nodeUrl.query; // #path = (#pathname)?(#search) nodeUrl.path = nodeUrl.pathname + nodeUrl.search; // #href = protocol://user:password@host.name:port/p/a/t/h?q=query nodeUrl.href += nodeUrl.search; } if (typeof url.hash === STRING) { // #hash nodeUrl.hash = SEARCH_SEPARATOR + (disableEncoding ? url.hash : encoder.encodeFragment(url.hash)); // #href = protocol://user:password@host.name:port/p/a/t/h?q=query#hash nodeUrl.href += nodeUrl.hash; } // Finally apply Node.js shenanigans // # Remove square brackets from IPv6 #hostname // Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/url.js#L399 // Refer: https://github.com/nodejs/node/blob/v12.18.3/lib/internal/url.js#L1273 if (hostname[0] === LEFT_SQUARE_BRACKET && hostname[hostname.length - 1] === RIGHT_SQUARE_BRACKET) { nodeUrl.hostname = hostname.slice(1, -1); } return nodeUrl; } /** * Resolves a relative URL with respect to given base URL. * This is a replacement method for Node's url.resolve() which is compatible * with URL object generated by toNodeUrl(). * * @example * // returns 'http://postman.com/baz' * resolveNodeUrl('http://postman.com/foo/bar', '/baz') * * @param {Object|String} base URL string or toNodeUrl() object * @param {String} relative Relative URL to resolve * @returns {String} Resolved URL */ function resolveNodeUrl (base, relative) { // normalize arguments typeof base === STRING && (base = toNodeUrl(base)); typeof relative !== STRING && (relative = E); // bail out if base is not an object if (!(base && typeof base === OBJECT)) { return relative; } let i, ii, index, baseHref, relative_0, relative_01, basePathname, requiredProps = ['protocol', 'auth', 'host', 'pathname', 'search', 'href']; // bail out if base is not like Node url object for (i = 0, ii = requiredProps.length; i < ii; i++) { if (!Object.hasOwnProperty.call(base, requiredProps[i])) { return relative; } } // cache base.href and base.pathname baseHref = base.href; basePathname = base.pathname; // cache relative's first two chars relative_0 = relative.slice(0, 1); relative_01 = relative.slice(0, 2); // @note relative can be one of // #1 empty string // #2 protocol relative, starts with // or \\ // #3 path relative, starts with / or \ // #4 just query or hash, starts with ? or # // #5 absolute URL, starts with :// or :\\ // #6 free from path, with or without query and hash // #1 empty string if (!relative) { // return base as it is if there is no hash if ((index = baseHref.indexOf(SEARCH_SEPARATOR)) === -1) { return baseHref; } // else, return base without the hash return baseHref.slice(0, index); } // #2 protocol relative, starts with // or \\ // @note \\ is not converted to // if (relative_01 === DOUBLE_SLASH || relative_01 === DOUBLE_BACK_SLASH) { return base.protocol + relative; } // #3 path relative, starts with / or \ // @note \(s) are not converted to / if (relative_0 === PATH_SEPARATOR || relative_0 === BACK_SLASH) { return getUrlTill(base, 'host') + relative; } // #4 just hash, starts with # if (relative_0 === SEARCH_SEPARATOR) { return getUrlTill(base, 'query') + relative; } // #4 just query, starts with ? if (relative_0 === QUERY_SEPARATOR) { return getUrlTill(base, 'pathname') + relative; } // #5 absolute URL, starts with :// or :\\ // @note :\\ is not converted to :// if (PROTOCOL_RE.test(relative)) { return relative; } // #6 free from path, with or without query and hash // remove last path segment form base path basePathname = basePathname.slice(0, basePathname.lastIndexOf(PATH_SEPARATOR) + 1); return getUrlTill(base, 'host') + basePathname + relative; } /** * Converts URL string into Node.js compatible Url object using the v1 encoder. * * @deprecated since version 2.0 * * @param {String} url URL string * @returns {Url} Node.js compatible Url object */ function toLegacyNodeUrl (url) { return legacy.toNodeUrl(url); } module.exports = { encode, toNodeUrl, resolveNodeUrl, toLegacyNodeUrl, encodeQueryString };