import querystring from 'querystring';
import finalhandler from 'finalhandler';
import util from 'util';
import { Resolver } from 'dns';
const trimQ = str => str.replace(/^"(.+)"$/g, '$1');
class Router {
/**
* Routes HTTP requests
* @param {Resolver} resolver The resolver object
*/
constructor(resolver) {
this._resolver = resolver;
this._attached = false;
}
/**
* Handle webfinger request
* @param URL url The URL of the incoming request
* @param https.ServerResponse res The response to send back to the server
*/
async getWebfinger(url, res) {
const query = querystring.parse(url.search.slice(1));
const resource = Array.isArray(query.resource) ? query.resource[0] : query.resource;
const [,username,domain] = resource.match(/^acct\:([^@]+)@(.+)$/i);
// resolveWebfinger gets resolver from name config
const actor = await this._resolver.resolveWebfinger(username, domain);
if(!actor) {
throw void 0;
}
const href = actor.id;
const body = JSON.stringify({
subject: resource,
links: [
{
rel: "self",
type: "application/activity+json",
href
}
]
});
res.setHeader('Content-Type', 'application/json');
res.end(body);
}
/**
* Handle a request for an activitypub object
* @param {URL} url The URL of the incoming request
* @param {https.ClientRequest} req The incoming request object
* @param {https.ServerResponse} res The resonse to send back
* @returns true if the request was handled successfuly, else false
*/
async getObject(url, req, res) {
const o = await this._resolver.objectFor(url);
if (!o) return false;
if(req.method === 'POST') {
try {
const actor = await this.verifySignature(url, req);
} catch(e) {
console.log(e);
throw e;
}
const data = await new Promise((res, rej) => {
let str = '';
req.on('data', d => str += d);
req.on('end', () => res(str));
});
const json = JSON.parse(data);
await o.post(json);
}
if(o.load) {
// E.g. get items
await o.load();
}
res.setHeader('Content-Type', 'application/activity+json');
res.end(JSON.stringify(o));
return true;
}
/**
* Handle an incoming HTTP request
* @param {https.ClientRequest} request The incoming request
* @param {https.ServerResponse} response The response to return
* @returns true if the request was handled successfuly, else false
*/
async handleRequest(request, response) {
const url = new URL(request.url, this._resolver.appRoot);
if(url.pathname === '/.well-known/webfinger') {
await this.getWebfinger(url, response);
return true;
} else {
return this.getObject(url, request, response);
}
// If method === post && request.header('content-type') === activity+json
// handlePost(object, request, response)
//
// If method === get && request.accepts('activity+json')
// handleGet(object, request, response)
//
// callback(object, request, response)
}
/**
* Attach request handlers to a server
* @param {https.Server} server The server to attach to
* @param {function} onRequest A request handler with signature (request, response, next)
* @returns {https.Server} The server (chainable)
*/
attach(server, onRequest) {
if(this._attached === true) {
throw new Error('You can only attach to one server at a time.');
}
server.on('request', async (req, res) => {
console.log('In request');
const done = finalhandler(req, res);
try {
if(onRequest) {
console.log('onRequest', onRequest);
await util.promisify(onRequest)(req, res);
}
console.log('Try handle');
const handled = await this.handleRequest(req, res);
console.log('Handled?', handled);
if(!handled) {
done();
}
} catch (e) {
console.log('Request erreur', e);
done(e);
}
});
return server;
}
/**
* Verify signature of incoming request is valid
* @param {url} url URL of incoming request
* @param {http.IncomingRequest} req Request to verify
*/
async verifySignature(url, req) {
// TODO: Require (request-target), host, date to be present
// TODO: Make sure sign date is not too old.
const signatureHeader = {};
if(!req.headers.signature) {
throw new Error('Request not signed');
}
// Extract keys and values from signature header
for(const pair of req.headers.signature.split(',')) {
const [, key, value] = pair.match(/^([^=]+)=(.+)/);
signatureHeader[key] = trimQ(value);
}
// Signature tells us where to find public key and
// which headers were used to sign the request
const {keyId, headers} = signatureHeader;
if(!keyId) {
throw new Error(`Request signature has no keyId`);
}
const signature = Buffer.from(signatureHeader.signature, 'base64');
// Get the public key
const actor = await new Promise((resolve, reject) => {
const options = {
headers: {
"Accept": "application/json"
}
};
options.rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== 'false';
require('https').get(keyId, options, response => {
const { statusCode } = response;
const contentType = response.headers['content-type'];
let error;
if (statusCode >= 400) {
error = new Error(
'Request Failed.\n' +
`Status Code: ${statusCode}`
);
} else if(!/\/(.+\+)?json(;|$)/.test(response.headers['content-type'])) {
error = new Error(
'Invalid content-type.\n' +
`Expected some kind of json but received ${contentType}`
);
}
if (error) {
reject(error);
// Consume response data to free up memory
response.resume();
return;
}
response.setEncoding('utf8');
let rawData = '';
response.on('data', (chunk) => { rawData += chunk; });
response.on('end', () => {
try {
const parsedData = JSON.parse(rawData);
resolve(parsedData);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
const key = actor.publicKey.publicKeyPem;
// Build the string to match the signature against
const comparisonString = headers.split(' ').map(name => {
if(name === '(request-target)') {
return `${name}: post ${url.pathname}`;
} else {
return `${name}: ${req.headers[name]}`;
}
}).join('\n');
const verifier = require('crypto').createVerify('SHA256');
verifier.write(comparisonString);
verifier.end();
if(verifier.verify(key, signature)) {
return actor;
}
throw new Error('Actor signature could not be verified');
}
}
export default Router;