aws/query.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

const util = require('util');
const url = require('url');
const logger = require(__dirname + '/../logger');
const lib = require(__dirname + '/../lib');
const aws = require(__dirname + '/../aws');

aws.fetch = function(url, options, callback)
{
    if (!options.retryCount) options.retryCount = this.retryCount[options.endpoint];
    if (!options.retryOnError && options.retryCount) options.retryOnError = 1;
    lib.fetch(url, options, callback);
}

aws.parseError = function(params, options)
{
    var err;
    if (params.obj) {
        var errors = params.obj.Response?.Errors?.Error;
        if (errors?.length && errors[0].Message) {
            err = lib.newError({ message: errors[0].Message, code: errors[0].Code, status: params.status });
        } else
        if (params.obj.ErrorResponse?.Error) {
            err = lib.newError({ message: params.obj.ErrorResponse.Error.Message, code: params.obj.ErrorResponse.Error.Code, status: params.status });
        } else
        if (params.obj.Error?.Message) {
            err = lib.newError({ message: params.obj.Error.Message, code: params.obj.Error.Code, status: params.status });
        } else
        if (params.obj.__type) {
            err = lib.newError({ message: params.obj.Message || params.obj.message, code: params.obj.__type, status: params.status });
        }
    }
    if (!err) {
        err = lib.newError({ message: "Error " + params.status + " " + params.data, status: params.status });
    }
    if (params.action) {
        err.action = params.action;
    }
    if (options?.ignore_error > 0 || lib.isFlag(options?.ignore_error, err.code)) err = null;
    return err;
}

/**
 * Parse AWS response and try to extract error code and message, convert XML into an object.
 * @memberof module:aws
 * @method parseXMLResponse
 */
aws.parseXMLResponse = function(err, params, options, callback)
{
    if (!err && params.data) {
        if (!params.obj) {
            params.obj = lib.xmlParse(params.data);
        }
        if (params.status < 200 || params.status >= 400) {
            err = this.parseError(params, options);
        }
        logger.logger(err ? options?.logger_error || "error" : "debug", "queryAWS:", params.href, params.search, params.Action, params.obj, err, params.toJSON());
    }
    if (typeof callback == "function") callback(err, params.obj);
}

aws.uriEscape = function(str)
{
    str = encodeURIComponent(str);
    str = str.replace(/[^A-Za-z0-9_.~\-%]+/g, escape);
    return str.replace(/[!'()*]/g, (ch) => ('%' + ch.charCodeAt(0).toString(16).toUpperCase()));
}

aws.uriEscapePath = function(path)
{
    return path ? String(path).split('/').map(aws.uriEscape).join('/') : "/";
}

// Check for supported regions per service, return the first one if the given region is not supported
aws.getServiceRegion = function(service, region)
{
    return this.regions[service] && this.regions[service].indexOf(region) == -1 ? this.regions[service][0] : region;
}

// Copy all credentials properties from the options into the obj
aws.copyCredentials = function(obj, options)
{
    for (var p in options) {
        if (/^(region|endpoint|credentials|endpoint_(protocol|host|path))$/.test(p)) obj[p] = options[p];
    }
    return obj;
}

/**
 * Build version 4 signature headers
 * @memberof module:aws
 * @method querySign
 */
aws.querySign = function(region, service, host, method, path, body, headers, credentials, options)
{
    if (!credentials) credentials = this;
    var now = util.types.isDate(options?.now) ? options.now : new Date();
    var isoDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
    var date = isoDate.substr(0, 8);

    headers.host = host;
    headers['x-amz-date'] = isoDate;
    if (body && !headers['content-type']) headers['content-type'] = 'application/x-www-form-urlencoded; charset=utf-8';
    if (body && !lib.toNumber(headers['content-length'])) headers['content-length'] = Buffer.byteLength(body, 'utf8');
    if (credentials.token) headers["x-amz-security-token"] = credentials.token;
    delete headers.Authorization;

    function trimAll(header) { return header.toString().trim().replace(/\s+/g, ' '); }
    var hash = headers["x-amz-content-sha256"] || lib.hash(body || '', "sha256", "hex");
    var credStr = [ date, region, service, 'aws4_request' ].join('/');
    var pathParts = path.split('?', 2);
    var signedHeaders = Object.keys(headers).map((key) => (key.toLowerCase())).sort().join(';');
    var canonHeaders = Object.keys(headers).sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1)).map((key) => (key.toLowerCase() + ':' + trimAll(String(headers[key])))).join('\n');
    var canonStr = [ method, this.uriEscapePath(pathParts[0]), pathParts[1] || '', canonHeaders + '\n', signedHeaders, hash].join('\n');
    var strToSign = [ 'AWS4-HMAC-SHA256', isoDate, credStr, lib.hash(canonStr, "sha256", "hex") ].join('\n');

    var sigKey = lib.sign(credentials.secret, credentials.key + "," + credStr, "sha256", "hex");
    var kCredentials = this._sigCache.map[sigKey];
    if (!kCredentials) {
        var kDate = lib.sign('AWS4' + credentials.secret, date, "sha256", "binary");
        var kRegion = lib.sign(kDate, region, "sha256", "binary");
        var kService = lib.sign(kRegion, service, "sha256", "binary");
        kCredentials = lib.sign(kService, 'aws4_request', "sha256", "binary");
        this._sigCache.map[sigKey] = kCredentials;
        this._sigCache.list.push(sigKey);
        if (this._sigCache.list.length > 25) delete this._sigCache.map[this._sigCache.list.shift()];
    }
    var sig = lib.sign(kCredentials, strToSign, "sha256", "hex");
    headers.Authorization = [ 'AWS4-HMAC-SHA256 Credential=' + credentials.key + '/' + credStr, 'SignedHeaders=' + signedHeaders, 'Signature=' + sig ].join(', ');
    if (options) {
        options.date = isoDate;
        options.signedHeaders = signedHeaders;
        options.credential = credentials.key + '/' + credStr;
        options.canonStr = canonStr;
        options.signature = sig;
    }
}

// Return a request object ready to be sent to AWS, properly formatted
aws.queryPrepare = function(action, version, obj, options)
{
    var req = { Action: action, Version: version };
    for (const p in obj) req[p] = obj[p];
    // All capitalized options are passed as is and take priority because they are in native format
    for (const p in options) {
        if (p[0] >= 'A' && p[0] <= 'Z' && typeof options[p] != "undefined" && options[p] !== null && options[p] !== "") {
            req[p] = options[p];
        }
    }
    return req;
}

aws.queryOptions = function(method, data, headers, options)
{
    return {
        method: method || options?.method || "POST",
        query: options?.query,
        qsopts: options?.qsopts,
        postdata: data,
        headers: headers,
        quiet: options?.quiet,
        retryCount: options?.retryCount,
        retryTimeout: options?.retryTimeout,
        retryOnError: options?.retryOnError,
        httpTimeout: options?.httpTimeout,
        credentials: options?.credentials,
    };
}

// It is called in the context of a http request
aws.querySigner = function()
{
    aws.querySign(this.region, this.endpoint, this.hostname, this.method, this.path, this.postdata, this.headers, this.credentials);
}

/**
 * Make AWS request, return parsed response as Javascript object or null in case of error
 * @memberof module:aws
 * @method queryAWS
 */
aws.queryAWS = function(region, service, proto, host, path, obj, options, callback)
{
    var headers = {}, params = [], postdata = "";
    for (var p in obj) {
        if (typeof obj[p] != "undefined" && obj[p] !== null && obj[p] !== "") params.push([p, obj[p]]);
    }
    params.sort();
    for (var i = 0; i < params.length; i++) {
        postdata += (i ? "&" : "") + params[i][0] + "=" + lib.encodeURIComponent(params[i][1]);
    }
    var opts = this.queryOptions("POST", postdata, headers, options);
    opts.region = region;
    opts.endpoint = service;
    opts.signer = this.querySigner;
    opts.action = obj.Action;
    logger.debug(opts.action, host, path, opts);
    this.fetch(url.format({ protocol: proto, host: host, pathname: path }), opts, (err, params) => {
        // For error logging about the current request
        params.Action = obj;
        aws.parseXMLResponse(err, params, options, callback);
    });
}

/**
 * AWS generic query interface
 * @memberof module:aws
 * @method queryEndpoint
 */
aws.queryEndpoint = function(service, version, action, obj, options, callback)
{
    if (typeof options == "function") callback = options, options = null;
    // Limit to the suppported region per endpoint
    var region = this.getServiceRegion(service, options?.region || this.region || 'us-east-1');
    // Specific endpoint url if it is different from the common endpoint.region.amazonaws.com
    var e = options?.endpoint ? URL.parse(String(options.endpoint)) :
            this.endpoints[service + "-" + region] ? url.parse(this.endpoints[service + "-" + region]) :
            this.endpoints[service] ? URL.parse(this.endpoints[service]) :
            lib.empty;
    var proto = options?.endpoint_protocol || e?.protocol || 'https';
    var host = options?.endpoint_host || (e?.host || e?.hostname) || (service + '.' + region + '.amazonaws.com');
    var path = options?.endpoint_path || (e?.path || e?.pathanme) || '/';
    var req = this.queryPrepare(action, version, obj, options);
    this.queryAWS(region, service, proto, host, path, req, options, callback);
}

/**
 * @memberof module:aws
 * @method queryService
 */
aws.queryService = function(endpoint, target, action, obj, options, callback)
{
    if (typeof options == "function") callback = options, options = null;

    var headers = { 'content-type': 'application/x-amz-json-1.1', 'x-amz-target': target + "." + action };
    var opts = this.queryOptions("POST", lib.stringify(obj), headers, options);
    opts.region = this.getServiceRegion(endpoint, options?.region || this.region || 'us-east-1');
    opts.action = action;
    opts.endpoint = endpoint;
    opts.signer = this.querySigner;
    logger.debug(opts.action, opts);
    this.fetch(`https://${endpoint}.${opts.region}.amazonaws.com/`, opts, (err, params) => {
        if (params.status != 200) err = aws.parseError(params, options);
        if (typeof callback == "function") callback(err, params.obj);
    });
}