@foxy.io/sdk
Version:
Universal SDK for a full server-side and a limited in-browser access to Foxy hAPI.
554 lines (553 loc) • 19.3 kB
JavaScript
import * as crypto from 'crypto';
import * as fs from 'fs';
import { JSDOM } from 'jsdom';
import { URL } from 'url';
/**
* HMAC signing utility. Methods are named after what it is to be signed, to
* allow for an easy to read code in the user application.
*
* @tutorial https://wiki.foxycart.com/v/2.0/hmac_validation
* @example const signer = new Signer(mySecret); // or const signer = new Signer(); signer.setSecret(mySecret);
* signer.signHtml('<html lang="en">...</html>'); // signs a URL
* signer.signFile("/var/www/html/src/.../index.html", "/var/www/html/target/.../index.html"); // signs an HTML file
* signer.signUrl("http://..."); // signs a URL
*/
export class Signer {
/**
* Creates an instance of this class.
*
* @param secret OAuth2 client secret for your integration.
*/
constructor(secret = null) {
this.__cartURL = '/cart?';
if (secret) {
this.setSecret(secret);
}
}
/**
* Sets the HMAC secret.
* It won't be possible to sign anything without this secret.
*
* @param secret OAuth2 client secret for your integration.
* @returns Signer to allow for convenient concatenation.
*/
setSecret(secret) {
this.__secret = secret;
return this;
}
/**
* Signs a whole HTML snippet.
*
* @param htmlStr HTML snippet to sign.
* @returns the HTML snippet signed.
*/
signHtml(htmlStr) {
const dom = new JSDOM(htmlStr);
this.__fragment(dom.window.document);
return dom.serialize();
}
/**
* Signs a file asynchronously.
*
* @param inputPath Path of the file to sign.
* @param outputPath Path of the file where the signed result will be stored.
* @param readFunc a function that should read from file
* @param writeFunc a function that should write to to file
* @returns a ParentNode object of the signed HTML.
*/
signFile(inputPath, outputPath, readFunc = JSDOM.fromFile, writeFunc = fs.writeFile) {
return new Promise((resolve, reject) => {
readFunc(inputPath).then(dom => {
const signed = this.__fragment(dom.window.document);
writeFunc(outputPath, dom.serialize(), err => {
if (err)
reject(err);
else
resolve(signed);
});
});
});
}
/**
* Signs a query string.
* All query fields withing the query string will be signed, provided it is a proper URL and there is a code field
*
* @param urlStr Full URL including the query string that needs to be signed.
* @returns the signed query string.
*/
signUrl(urlStr) {
// Build a URL object
if (Signer.__isSigned(urlStr)) {
console.error('Attempt to sign a signed URL', urlStr);
return urlStr;
}
const cartURLpattern = new RegExp(this.__cartURL, 'i');
if (!cartURLpattern.test(urlStr)) {
return urlStr;
}
// Do not change invalid URLs
let url;
let stripped;
try {
url = new URL(urlStr);
stripped = new URL(url.origin);
}
catch (e) {
//console.assert(e.code === "ERR_INVALID_URL");
return urlStr;
}
const originalParams = url.searchParams;
const newParams = stripped.searchParams;
const code = Signer.__getCodeFromURL(url);
// If there is no code, return the same URL
if (!code) {
return urlStr;
}
// sign the url object
for (const p of originalParams.entries()) {
const signed = this.__signQueryArg(decodeURIComponent(p[0]), decodeURIComponent(code), decodeURIComponent(p[1])).split('=');
newParams.set(signed[0], signed[1]);
}
url.search = newParams.toString();
const result = decodeURIComponent(url.toString());
return result;
}
/**
* Signs input name.
*
* @param name Name of the input element.
* @param code Product code.
* @param parentCode Parent product code.
* @param value Input value.
* @returns the signed input name.
*/
signName(name, code, parentCode = '', value) {
name = name.replace(/ /g, '_');
if (this.__shouldSkipInput(name)) {
return name;
}
else {
const signature = this.__signProduct(code + parentCode, name, value);
const encodedName = encodeURIComponent(name);
const nameAttr = Signer.__buildSignedName(encodedName, signature, value);
return nameAttr;
}
}
/**
* Signs input value.
*
* @param name Name of the input element.
* @param code Product code.
* @param parentCode Parent product code.
* @param value Input value.
* @returns the signed value.
*/
signValue(name, code, parentCode = '', value) {
name = name.replace(/ /g, '_');
if (this.__shouldSkipInput(name)) {
return value;
}
else {
const signature = this.__signProduct(code + parentCode, name, value);
const valueAttr = Signer.__buildSignedValue(signature, value);
return valueAttr;
}
}
/**
* Signs a product composed of code, name and value.
*
* @param code of the product.
* @param name name of the product.
* @param value of the product.
* @returns the signed product.
* @private
*/
__signProduct(code, name, value) {
return this.__message(code + name + Signer.__valueOrOpen(value));
}
/**
* Signs a single query argument to be used in `GET` requests.
*
* @param name of the argument.
* @param code of the product.
* @param value of the argument.
* @returns the signed query argument.
* @private
*/
__signQueryArg(name, code, value) {
name = name.replace(/ /g, '_');
if (this.__shouldSkipInput(name)) {
return `${name}=${value}`;
}
else {
code = code.replace(/ /g, '_');
const signature = this.__signProduct(code, name, value);
const encodedName = encodeURIComponent(name).replace(/%20/g, '+');
const encodedValue = encodeURIComponent(Signer.__valueOrOpen(value)).replace(/%20/g, '+');
return Signer.__buildSignedQueryArg(encodedName, signature, encodedValue);
}
}
/**
* Signs an input element.
*
* @param el the input element
* @param codes the codes dict object containing the code and parent code
* @returns the signed element
* @private
*/
__signInput(el, codes) {
const namePrefix = Signer.__splitNamePrefix(el.name);
const nameString = namePrefix[1];
const prefix = namePrefix[0];
const code = codes[prefix].code;
const parentCode = codes[prefix].parent;
const value = el.value;
const signedName = this.signName(nameString, code, parentCode, value);
el.setAttribute('name', prefix + ':' + signedName);
return el;
}
/**
* Signs a texArea element.
*
* @param el the textArea element.
* @param codes the codes dict object containing the code and parent code
* @returns the signed textarea element.
* @private
*/
__signTextArea(el, codes) {
const namePrefix = Signer.__splitNamePrefix(el.name);
const nameString = namePrefix[1];
const prefix = namePrefix[0];
const code = codes[prefix].code;
const parentCode = codes[prefix].parent;
const value = '';
const signedName = this.signName(nameString, code, parentCode, value);
el.setAttribute('name', prefix + ':' + signedName);
return el;
}
/**
* Signs all option elements within a Select element.
*
* @param el the select element.
* @param codes the codes dict object containing the code and parent code.
* @returns the signed select element.
* @private
*/
__signSelect(el, codes) {
el.querySelectorAll('option').forEach(opt => {
this.__signOption(opt, codes);
});
return el;
}
/**
* Sign an option element.
* Signatures are added to the value attribute on options.
* This function may also be used to sign radio buttons.
*
* @param el the option element to be signed.
* @param codes the codes dict object containing the code and parent code.
* @returns the signed option element.
* @private
*/
__signOption(el, codes) {
// Get the name parameter, either from the "select"
// parent element of an option tag or from the name
// attribute of the input element itself
let n = el.name;
if (n === undefined) {
const p = el.parentElement;
n = p.name;
}
const namePrefix = Signer.__splitNamePrefix(n);
const nameString = namePrefix[1];
const prefix = namePrefix[0];
const code = codes[prefix].code;
const parentCode = codes[prefix].parent;
const value = el.value;
const signedValue = this.signValue(nameString, code, parentCode, value);
el.setAttribute('value', prefix + ':' + signedValue);
return el;
}
/**
* Signs a radio button. Radio buttons use the value attribute to hold their signatures.
*
* @param el the radio button element.
* @param codes the codes dict object containing the code and parent code.
* @returns the signed radio button.
* @private
*/
__signRadio(el, codes) {
return this.__signOption(el, codes);
}
/**
* Splits a string using the prefix pattern for foxy store.
* The prefix pattern allows for including more than a single product in a given GET or POST request.
*
* @param name the name to be separated into prefix and name.
* @returns an array with [prefix, name]
* @private
*/
static __splitNamePrefix(name) {
const namePrefix = name.split(':');
if (namePrefix.length == 2) {
return [parseInt(namePrefix[0], 10), namePrefix[1]];
}
return [0, name];
}
/**
* Retrieve a parent code value from a form, given a prefix.
*
* @param formElement the element with the code and parent code values.
* @param prefix the prefix used in hte element.
* @returns the parentCode
* @private
*/
static __retrieveParentCode(formElement, prefix = '') {
let result = ''; // A blank string indicates no parent
const separator = prefix ? ':' : '';
const parentCodeEl = formElement.querySelector(`[name='${prefix}${separator}parent_code']`);
if (parentCodeEl) {
const parentCode = parentCodeEl.getAttribute('value');
if (parentCode !== null) {
result = parentCode;
}
}
return result;
}
/**
* Signs a whole form element.
*
* @param formElement the form element to be signed.
* @private
*/
__signForm(formElement) {
var _a;
// Grab all codes within the form element
const codeList = formElement.querySelectorAll('[name$=code]');
// Store all codes in a object
const codes = {};
for (const node of codeList) {
const nameAttr = node.getAttribute('name');
const codeValue = (_a = node.getAttribute('value')) !== null && _a !== void 0 ? _a : '';
if (nameAttr && nameAttr.match(/^([0-9]{1,3}:)?code/)) {
const namePrefix = nameAttr.split(':');
const prefix = parseInt(namePrefix[0]);
if (namePrefix.length == 2) {
// Store prefix in codes list
codes[prefix] = {
code: codeValue,
parent: Signer.__retrieveParentCode(formElement, prefix),
};
}
else if (codes[0] === undefined) {
// Allow to push a single code without prefix
codes[0] = {
code: codeValue,
parent: Signer.__retrieveParentCode(formElement),
};
}
else {
const documentationURL = 'https://wiki.foxycart.com/v/2.0/hmac_validation#multiple_products_in_one_form';
const errorMsg = `There are multiple codes in the form element. Please, check ${documentationURL}`;
throw new Error(errorMsg);
}
}
}
// Sign inputs
formElement.querySelectorAll('input[name]').forEach(i => {
if (i.getAttribute('type') === 'radio') {
this.__signRadio(i, codes);
}
else {
this.__signInput(i, codes);
}
});
// Sign selects
formElement.querySelectorAll('select[name]').forEach(s => this.__signSelect(s, codes));
// Sign textAreas
formElement.querySelectorAll('textarea[name]').forEach(s => this.__signTextArea(s, codes));
}
/**
* Builds the value for the signed "name" attribute value given it components.
*
* @param name that was signed
* @param signature the resulting signature
* @param value of the field that, if equal to --OPEN-- identifies an editable field.
* @returns the signed value for the "name" attribute
* @private
*/
static __buildSignedName(name, signature, value) {
const open = Signer.__valueOrOpen(value) == '--OPEN--' ? '||open' : '';
return `${name}||${signature}${open}`;
}
/**
* Builds a signed name given it components.
*
* @param signature the resulting signature.
* @param value the value signed.
* @returns the built signed value
* @private
*/
static __buildSignedValue(signature, value) {
const open = Signer.__valueOrOpen(value) == '--OPEN--' ? '||open' : value;
return `${open}||${signature}`;
}
/**
* Builds a signed query argument given its components.
*
* @param name the argument name.
* @param signature the resulting signature.
* @param value the value signed.
* @returns the built query string argument.
* @private
*/
static __buildSignedQueryArg(name, signature, value) {
return `${name}||${signature}=${value}`;
}
/**
* Returns the value of a field on the `--OPEN--` string if the value is not defined.
* Please, notice that `0` is an acceptable value.
*
* @param value of the field.
* @returns '--OPEN--' or the given value.
* @private
*/
static __valueOrOpen(value) {
if (value === undefined || value === null || value === '') {
return '--OPEN--';
}
return value;
}
/**
* Check if a href string is already signed. Signed strings contain two consecutive pipes
* followed by 64 hexadecimal characters.
*
* This method **does not validates the signature**.
* It only checks if the format of the string is evidence that it is signed.
*
* @param url the potentially signed URL.
* @returns true if the string format is evidence that it is already signed.
* @private
*/
static __isSigned(url) {
return url.match(/^.*\|\|[0-9a-fA-F]{64}/) != null;
}
/**
* Returns the code from a URL or undefined if it does not contain a code.
*
* @param url the URL to retrieve the code from.
* @returns the code found, or undefined if no code was found.
* @private
*/
static __getCodeFromURL(url) {
for (const p of url.searchParams) {
if (p[0] == 'code') {
return p[1];
}
}
}
/**
* Find all cart forms in a document fragment that contain an input named `code`.
*
* @param doc the document fragment potentially containing cart forms.
* @returns an array of the form elements found.
* @private
*/
static __findCartForms(doc) {
return Array.from(doc.querySelectorAll('form')).filter(e => e.querySelector('[name=code]'));
}
/**
* Signs a document fragment. This method is used to sign HTML snippets.
*
* @param doc an HTML doc fragment
* @returns the signed HTML snippet
* @private
*/
__fragment(doc) {
doc.querySelectorAll(`a[href*='${this.__cartURL}']`).forEach(l => {
const anchor = l;
anchor.href = this.signUrl(anchor.href);
});
Signer.__findCartForms(doc).forEach(this.__signForm.bind(this));
return doc;
}
/**
* Signs a simple message. This function can only be invoked after the secret has been defined. The secret can be defined either in the construction method as in `new FoxySigner(mySecret)` or by invoking the setSecret method, as in `signer.setSecret(mySecret)`
*
*
* @param message the message to be signed.
* @returns signed message.
* @private
*/
__message(message) {
if (this.__secret === undefined) {
throw new Error('No secret was provided to build the hmac');
}
const hmac = crypto.createHmac('sha256', this.__secret);
hmac.update(message);
return hmac.digest('hex');
}
/**
* Checks if a name should be skipped.
*
* @param name that could be signed
* @returns it should be skipped, i.e. not be signed
* @private
*/
__shouldSkipInput(name) {
const prefixStripped = name.replace(/^\d:/, "");
return (Signer.cartExcludes.includes(prefixStripped) ||
Signer.cartExcludePrefixes.some((p) => name.toLowerCase().startsWith(p) ||
(name.startsWith("0:") && prefixStripped.toLowerCase().startsWith(p))));
}
}
Signer.cartExcludes = [
// Analytics values
"_",
"_ga",
"_ke",
// Cart values
"cart",
"fcsid",
"empty",
"coupon",
"output",
"sub_token",
"redirect",
"callback",
"locale",
"template_set",
// Checkout pre-population values
"customer_email",
"customer_first_name",
"customer_last_name",
"customer_address1",
"customer_address2",
"customer_city",
"customer_state",
"customer_postal_code",
"customer_country",
"customer_phone",
"customer_company",
"billing_first_name",
"billing_last_name",
"billing_address1",
"billing_address2",
"billing_city",
"billing_postal_code",
"billing_region",
"billing_phone",
"billing_company",
"shipping_first_name",
"shipping_last_name",
"shipping_address1",
"shipping_address2",
"shipping_city",
"shipping_state",
"shipping_country",
"shipping_postal_code",
"shipping_region",
"shipping_phone",
"shipping_company",
];
Signer.cartExcludePrefixes = ["h:", "x:", "__", "utm_"];