insomnia/plugins/insomnia-plugin-request/index.js
Jack Kavanagh ef7f67cf5c
move insomnia-url helpers to src (#5389)
* remove insomnia-url package

* duplicate all that noise

* fix tests and decouple insomnia-cookies

* update package lock

* fix test
2022-11-15 11:12:45 +00:00

482 lines
14 KiB
JavaScript

const { format, parse } = require('url');
/**
* URL encode a string in a flexible way
* @param str string to encode
* @param ignore characters to ignore
*/
const flexibleEncodeComponent = (str = '', ignore = '') => {
// Sometimes spaces screw things up because of url.parse
str = str.replace(/%20/g, ' ');
// Handle all already-encoded characters so we don't touch them
str = str.replace(/%([0-9a-fA-F]{2})/g, '__ENC__$1');
// Do a special encode of ignored chars, so they aren't touched.
// This first pass, surrounds them with a special tag (anything unique
// will work), so it can change them back later
// Example: will replace %40 with __LEAVE_40_LEAVE__, and we'll change
// it back to %40 at the end.
for (const c of ignore) {
const code = encodeURIComponent(c).replace('%', '');
const escaped = c.replace(ESCAPE_REGEX_MATCH, '\\$&');
const re2 = new RegExp(escaped, 'g');
str = str.replace(re2, `__RAW__${code}`);
}
// Encode it
str = encodeURIComponent(str);
// Put back the raw version of the ignored chars
for (const match of str.match(/__RAW__([0-9a-fA-F]{2})/g) || []) {
const code = match.replace('__RAW__', '');
str = str.replace(match, decodeURIComponent(`%${code}`));
}
// Put back the encoded version of the ignored chars
for (const match of str.match(/__ENC__([0-9a-fA-F]{2})/g) || []) {
const code = match.replace('__ENC__', '');
str = str.replace(match, `%${code}`);
}
return str;
};
/**
* Build a querystring parameter from a param object
*/
const buildQueryParameter = (
param,
/** allow empty names and values */
strict,
) => {
strict = strict === undefined ? true : strict;
// Skip non-name ones in strict mode
if (strict && !param.name) {
return '';
}
// Cast number values to strings
if (typeof param.value === 'number') {
param.value = String(param.value);
}
if (!strict || param.value) {
// Don't encode ',' in values
const value = flexibleEncodeComponent(param.value || '').replace(/%2C/gi, ',');
const name = flexibleEncodeComponent(param.name || '');
return `${name}=${value}`;
} else {
return flexibleEncodeComponent(param.name);
}
};
/**
* Build a querystring from a list of name/value pairs
*/
const buildQueryStringFromParams = (
parameters,
/** allow empty names and values */
strict,
) => {
strict = strict === undefined ? true : strict;
const items = [];
for (const param of parameters) {
const built = buildQueryParameter(param, strict);
if (!built) {
continue;
}
items.push(built);
}
return items.join('&');
};
const getJoiner = (url) => {
url = url || '';
return url.indexOf('?') === -1 ? '?' : '&';
};
const joinUrlAndQueryString = (url, qs) => {
if (!qs) {
return url;
}
if (!url) {
return qs;
}
const [base, ...hashes] = url.split('#');
// TODO: Make this work with URLs that have a #hash component
const baseUrl = base || '';
const joiner = getJoiner(base);
const hash = hashes.length ? `#${hashes.join('#')}` : '';
return `${baseUrl}${joiner}${qs}${hash}`;
};
const setDefaultProtocol = (url, defaultProto) => {
const trimmedUrl = url.trim();
defaultProto = defaultProto || 'http:';
// If no url, don't bother returning anything
if (!trimmedUrl) {
return '';
}
// Default the proto if it doesn't exist
if (trimmedUrl.indexOf('://') === -1) {
return `${defaultProto}//${trimmedUrl}`;
}
return trimmedUrl;
};
/**
* Deconstruct a querystring to name/value pairs
* @param [qs] {string}
* @param [strict=true] {boolean} - allow empty names and values
* @returns {{name: string, value: string}[]}
*/
const deconstructQueryStringToParams = (
qs,
/** allow empty names and values */
strict,
) => {
strict = strict === undefined ? true : strict;
const pairs = [];
if (!qs) {
return pairs;
}
const stringPairs = qs.split('&');
for (const stringPair of stringPairs) {
// NOTE: This only splits on first equals sign. '1=2=3' --> ['1', '2=3']
const [encodedName, ...encodedValues] = stringPair.split('=');
const encodedValue = encodedValues.join('=');
let name = '';
try {
name = decodeURIComponent(encodedName || '');
} catch (error) {
// Just leave it
name = encodedName;
}
let value = '';
try {
value = decodeURIComponent(encodedValue || '');
} catch (error) {
// Just leave it
value = encodedValue;
}
if (strict && !name) {
continue;
}
pairs.push({ name, value });
}
return pairs;
};
const ESCAPE_REGEX_MATCH = /[-[\]/{}()*+?.\\^$|]/g;
/** see list of allowed characters https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 */
const RFC_3986_GENERAL_DELIMITERS = ':@'; // (unintentionally?) missing: /?#[]
/** see list of allowed characters https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 */
const RFC_3986_SUB_DELIMITERS = '$+,;='; // (unintentionally?) missing: !&'()*
/** see list of allowed characters https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 */
const URL_PATH_CHARACTER_WHITELIST = `${RFC_3986_GENERAL_DELIMITERS}${RFC_3986_SUB_DELIMITERS}`;
/**
* Automatically encode the path and querystring components
* @param url url to encode
* @param encode enable encoding
*/
const smartEncodeUrl = (url, encode) => {
// Default autoEncode = true if not passed
encode = encode === undefined ? true : encode;
const urlWithProto = setDefaultProtocol(url);
if (!encode) {
return urlWithProto;
} else {
// Parse the URL into components
const parsedUrl = parse(urlWithProto);
// ~~~~~~~~~~~ //
// 1. Pathname //
// ~~~~~~~~~~~ //
if (parsedUrl.pathname) {
const segments = parsedUrl.pathname.split('/');
parsedUrl.pathname = segments
.map(s => flexibleEncodeComponent(s, URL_PATH_CHARACTER_WHITELIST))
.join('/');
}
// ~~~~~~~~~~~~~~ //
// 2. Querystring //
// ~~~~~~~~~~~~~~ //
if (parsedUrl.query) {
const qsParams = deconstructQueryStringToParams(parsedUrl.query);
const encodedQsParams = [];
for (const { name, value } of qsParams) {
encodedQsParams.push({
name: flexibleEncodeComponent(name),
value: flexibleEncodeComponent(value),
});
}
parsedUrl.query = buildQueryStringFromParams(encodedQsParams);
parsedUrl.search = `?${parsedUrl.query}`;
}
return format(parsedUrl);
}
};
module.exports.templateTags = [
{
name: 'request',
displayName: 'Request',
description: 'reference value from current request',
args: [
{
displayName: 'Attribute',
type: 'enum',
options: [
{
displayName: 'Name',
value: 'name',
description: 'name of request',
},
{
displayName: 'Folder',
value: 'folder',
description: 'name of parent folder (or workspace)',
},
{
displayName: 'URL',
value: 'url',
description: 'fully qualified URL',
},
{
displayName: 'Query Parameter',
value: 'parameter',
description: 'query parameter by name',
},
{
displayName: 'Header',
value: 'header',
description: 'header value by name',
},
{
displayName: 'Cookie',
value: 'cookie',
description: 'cookie value by name',
},
{
displayName: 'OAuth 2.0 Access Token',
value: 'oauth2',
/*
This value is left as is and not renamed to 'oauth2-access' so as to not
break the current release's usage of `oauth2`.
*/
},
{
displayName: 'OAuth 2.0 Identity Token',
value: 'oauth2-identity',
},
{
displayName: 'OAuth 2.0 Refresh Token',
value: 'oauth2-refresh',
},
],
},
{
type: 'string',
hide: args =>
['url', 'oauth2', 'oauth2-identity', 'oauth2-refresh', 'name', 'folder'].includes(
args[0].value,
),
displayName: args => {
switch (args[0].value) {
case 'cookie':
return 'Cookie Name';
case 'parameter':
return 'Query Parameter Name';
case 'header':
return 'Header Name';
default:
return 'Name';
}
},
},
{
hide: args => args[0].value !== 'folder',
displayName: 'Parent Index',
help: 'Specify an index (Starting at 0) for how high up the folder tree to look',
type: 'number',
},
],
async run(context, attribute, name, folderIndex) {
const { meta } = context;
if (!meta.requestId || !meta.workspaceId) {
return null;
}
const request = await context.util.models.request.getById(meta.requestId);
const workspace = await context.util.models.workspace.getById(meta.workspaceId);
if (!request) {
throw new Error(`Request not found for ${meta.requestId}`);
}
if (!workspace) {
throw new Error(`Workspace not found for ${meta.workspaceId}`);
}
switch (attribute) {
case 'url':
return getRequestUrl(context, request);
case 'cookie':
if (!name) {
throw new Error('No cookie specified');
}
const cookieJar = await context.util.models.cookieJar.getOrCreateForWorkspace(workspace);
const url = await getRequestUrl(context, request);
return getCookieValue(cookieJar, url, name);
case 'parameter':
if (!name) {
throw new Error('No query parameter specified');
}
const parameterNames = [];
if (request.parameters.length === 0) {
throw new Error('No query parameters available');
}
for (const queryParameter of request.parameters) {
const queryParameterName = await context.util.render(queryParameter.name);
parameterNames.push(queryParameterName);
if (queryParameterName.toLowerCase() === name.toLowerCase()) {
return context.util.render(queryParameter.value);
}
}
const parameterNamesStr = parameterNames.map(n => `"${n}"`).join(',\n\t');
throw new Error(
`No query parameter with name "${name}".\nChoices are [\n\t${parameterNamesStr}\n]`,
);
case 'header':
if (!name) {
throw new Error('No header specified');
}
const headerNames = [];
if (request.headers.length === 0) {
throw new Error('No headers available');
}
for (const header of request.headers) {
const headerName = await context.util.render(header.name);
headerNames.push(headerName);
if (headerName.toLowerCase() === name.toLowerCase()) {
return context.util.render(header.value);
}
}
const headerNamesStr = headerNames.map(n => `"${n}"`).join(',\n\t');
throw new Error(`No header with name "${name}".\nChoices are [\n\t${headerNamesStr}\n]`);
case 'oauth2':
const access = await context.util.models.oAuth2Token.getByRequestId(request._id);
if (!access || !access.accessToken) {
throw new Error('No OAuth 2.0 access tokens found for request');
}
return access.accessToken;
case 'oauth2-identity':
const identity = await context.util.models.oAuth2Token.getByRequestId(request._id);
if (!identity || !identity.identityToken) {
throw new Error('No OAuth 2.0 identity tokens found for request');
}
return identity.identityToken;
case 'oauth2-refresh':
const refresh = await context.util.models.oAuth2Token.getByRequestId(request._id);
if (!refresh || !refresh.refreshToken) {
throw new Error('No OAuth 2.0 refresh tokens found for request');
}
return refresh.refreshToken;
case 'name':
return request.name;
case 'folder':
const ancestors = await context.util.models.request.getAncestors(request);
const doc = ancestors[folderIndex || 0];
if (!doc) {
throw new Error(
`Could not get folder by index ${folderIndex}. Must be between 0-${ancestors.length -
1}`,
);
}
return doc ? doc.name : null;
}
return null;
},
},
];
async function getRequestUrl(context, request) {
const url = await context.util.render(request.url);
const parameters = [];
for (const p of request.parameters) {
parameters.push({
name: await context.util.render(p.name),
value: await context.util.render(p.value),
});
}
const qs = buildQueryStringFromParams(parameters);
const finalUrl = joinUrlAndQueryString(url, qs);
return smartEncodeUrl(finalUrl, request.settingEncodeUrl);
}
const { CookieJar } = require('tough-cookie');
/**
* Get a request.jar() from a list of cookie objects
*/
const jarFromCookies = (cookies) => {
let jar;
try {
// For some reason, fromJSON modifies `cookies`.
// Create a copy first just to be sure.
const copy = JSON.stringify({ cookies });
jar = CookieJar.fromJSON(copy);
} catch (error) {
console.log('[cookies] Failed to initialize cookie jar', error);
jar = new CookieJar();
}
jar.rejectPublicSuffixes = false;
jar.looseMode = true;
return jar;
};
function getCookieValue(cookieJar, url, name) {
return new Promise((resolve, reject) => {
const jar = jarFromCookies(cookieJar.cookies);
jar.getCookies(url, {}, (err, cookies) => {
if (err) {
console.warn(`Failed to find cookie for ${url}`, err);
}
if (!cookies || cookies.length === 0) {
reject(new Error(`No cookies in store for url "${url}"`));
}
const cookie = cookies.find(cookie => cookie.key === name);
if (!cookie) {
const names = cookies.map(c => `"${c.key}"`).join(',\n\t');
throw new Error(
`No cookie with name "${name}".\nChoices are [\n\t${names}\n] for url "${url}"`,
);
} else {
resolve(cookie ? cookie.value : null);
}
});
});
}