diff --git a/.eslintrc.json b/.eslintrc.json index dcd7426..8894464 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -67,6 +67,9 @@ ], "jsdoc/require-param-description": [ "off" + ], + "no-async-promise-executor": [ + "off" ] } } diff --git a/lib/http_agent.js b/lib/http_agent.js new file mode 100644 index 0000000..f51d9b1 --- /dev/null +++ b/lib/http_agent.js @@ -0,0 +1,122 @@ +'use strict'; + +const crypto = require('crypto'); +const https = require('https'); +const http = require('http'); +const zlib = require('zlib'); + +const { getKeyPair } = require('./keys'); + +const handleDataDecode = (requestUrl, options, res, resolve, reject, data) => { + if ( options && (!options["headers"] || !options["headers"]["accept-encoding"]) && res.headers["content-encoding"] && res.headers["content-encoding"].toLowerCase() === "br") { + delete res.headers["content-encoding"]; + zlib.brotliDecompress(data, (error, result) => { + if ( error ) { + reject(error); + } else { + handleDataDecode(requestUrl, options, res, resolve, reject, result); + } + }); + } else if ( options && (!options["headers"] || !options["headers"]["accept-encoding"]) && res.headers["content-encoding"] && res.headers["content-encoding"].toLowerCase() === "gzip") { + delete res.headers["content-encoding"]; + zlib.gunzip(data, (error, result) => { + if ( error ) { + reject(error); + } else { + handleDataDecode(requestUrl, options, res, resolve, reject, result); + } + }); + } else { + let isBuffer = true; + if ( (!options["headers"] || !options["headers"]["accept-encoding"]) && res.headers["content-type"] ) { + var charset = /.*charset=(\S+)/.exec(res.headers["content-type"])[1]; + if ( charset.toLowerCase() === "iso-8859-1" ) { + charset = "latin1"; + } + if ( charset ) { + isBuffer = false; + data = data.toString(charset); + } + } + resolve({headers: res.headers, data, isBuffer}); + } +}; + +const handleCallback = (requestUrl, options, res, resolve, reject) => { + let data; + data = Buffer.alloc(0); + + res.on('data', (chunk) => { + data = Buffer.concat([data, chunk]); + }); + + res.on('end', () => { + handleDataDecode(requestUrl, options, res, resolve, reject, data); + }); + + res.on('error', (error) => reject(error)); +}; + +module.exports = { + request: (requestUrl, options) => { + options = options??{}; + // TODO: Support following redirects. + options["follow-redirects"] = options["follow-redirects"]??true; + var headers = {"accept-encoding": "br, gzip", ...options["headers"]}; + return new Promise(async (resolve, reject) => { + const parsedUrl = new URL(requestUrl); + var method = "GET"; + if ( options["method"] ) { + method = options["method"]; + } + var body = ""; + if ( method === "POST" ) { + if ( typeof options["body"] === "string" ) { + body = options["body"]; + } else if ( Buffer.isBuffer(options["body"]) ) { + body = options["body"]; + } else if ( options["headers"]["content-type"].toLowerCase() === "application/ld+json" || + options["headers"]["content-type"].toLowerCase() === "application/activity+json" || + options["headers"]["content-type"].toLowerCase() === "application/json") { + body = JSON.stringify(options["body"]); + } else { + return reject(new Error("Unrecognized body content-type, passed as object.")); + } + } + if ( body !== "" ) { + options["headers"]["content-length"] = Buffer.byteLength(body); + } + if ( options["actor"] ) { + var keyPair = getKeyPair("actor"); + var rsaSha256Sign = crypto.createSign("RSA-SHA256"); + headers["date"] = headers["date"]??(new Date()).toUTCString(); + var toSign = `(request-target): ${method.toLowerCase()} ${parsedUrl.pathname}?${parsedUrl.search}\nhost: ${parsedUrl.host}\ndate: ${headers["date"]}`; + if ( method === "POST" ) { + var digest = crypto.createHash("sha256").update(body).end().digest("hex"); + headers["digest"] = digest; + toSign = `${toSign}\ndigest: ${headers["digest"]}`; + } + headers["signature"] = `keyId=${options["actor"]}#main-key,headers="(request-target) host date${method === "post" ? " digest":""}",signature=${rsaSha256Sign.update(toSign).end().sign((await keyPair).privateKey, "base64")}`; + } + if ( parsedUrl.protocol.toLowerCase() === "https:" ) { + const httpsRequest = https.request(parsedUrl, {headers}, (res) => { + handleCallback(requestUrl, options, res, resolve, reject); + }).on('error', (error) => reject(error)); + if ( body !== "" ) { + httpsRequest.write(body); + } + httpsRequest.end(); + } else if ( parsedUrl.protocol.toLowerCase() === "http:" ) { + const httpRequest = http.request(parsedUrl, {headers}, (res) => { + handleCallback(requestUrl, options, res, resolve, reject); + }).on('error', (error) => reject(error)); + if ( body !== "" ) { + httpRequest.write(body); + } + httpRequest.end(); + } else { + reject("Unrecognized protocol."); + } + }); + } +}; diff --git a/lib/keys.js b/lib/keys.js index 5edb6f5..be14534 100644 --- a/lib/keys.js +++ b/lib/keys.js @@ -19,8 +19,8 @@ var getKeyPair = async (actor) => { var result = await db('keys').where({actor}).andWhere('expiry', '>', (new Date()).getTime()/1000); if ( result.length != 0 ) { return { - publicKey: result[0].public, - privateKey: result[0].private + publicKey: result[0].public.toString(), + privateKey: result[0].private.toString() }; } else { var {publicKey, privateKey} = await generateKeyPair('rsa', { @@ -34,12 +34,12 @@ var getKeyPair = async (actor) => { }); await db('keys').insert({ expiry: (new Date().setDate(new Date().getDate()+7).getTime()/1000), - public: publicKey, - private: privateKey, + public: publicKey.toString(), + private: privateKey.toString(), actor: actor }); return { - publicKey, privateKey + publicKey: publicKey.toString(), privateKey: publicKey.toString() }; } };