lib/host/HostHttpServer.js
const assert = require('assert')
const body = require('body')
const http = require('http')
const path = require('path')
const send = require('send')
const url = require('url')
const pathIsInside = require('path-is-inside')
const httpShutdown = require('http-shutdown')
/**
* A HTTP server for a `Host`
*/
class HostHttpServer {
constructor (host, address = '127.0.0.1', port = 2000) {
assert(host && host.constructor.name === 'Host', 'host must be an instance of Host')
this._host = host
this._address = address
this._port = port
this._server = null
}
get address () {
return this._address
}
get port () {
return this._port
}
/**
* Get the URL of this server
*
* @return {string} - Server's URL, `null` if not serving
*/
get url () {
return this._server ? ('http://' + this._address + ':' + this._port) : null
}
/**
* Start this server
*
* @return {Promise}
*/
start () {
return new Promise((resolve, reject) => {
if (!this._server) {
if (require('../host/Host').isSuperUser()) {
return reject(new Error('Serving host as a super user is dangerous and is not allowed'))
}
let server = http.createServer(this.handle.bind(this))
server = httpShutdown(server)
server.on('error', error => {
if (error.code === 'EADDRINUSE') {
this._port += 10
server.close()
server.listen(this._port, this._address, 511)
} else {
reject(error)
}
})
server.on('listening', () => {
resolve()
})
server.listen(this._port, this._address, 511)
this._server = server
} else {
resolve()
}
})
}
/**
* Stop this server
*
* @return {Promise}
*/
stop () {
return new Promise((resolve) => {
if (this._server) {
this._server.shutdown()
this._server = null
}
resolve()
})
}
/**
* Handle a HTTP request
*/
handle (request, response) {
let uri = url.parse(request.url, true)
// Check authorization
let authorized = false
if (!this._host.key) {
authorized = true
} else {
const authHeader = request.headers.authorization
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)/)
if (match) {
let token = match[1]
try {
this._host.authorizeToken(token)
authorized = true
} catch (error) {
return this.error403(request, response, error.message)
}
}
}
}
// Add CORS headers used to control access by browsers. In particular, CORS
// can prevent access by XHR requests made by Javascript in third party sites.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
// Get the Origin header (sent in CORS and POST requests) and fall back to Referer header
// if it is not present (either of these should be present in most browser requests)
let origin = request.headers.origin
if (!origin && request.headers.referer) {
let uri = url.parse(request.headers.referer || '')
origin = `${uri.protocol}//${uri.host}`
}
// Check that origin is in whitelist of file://, http://127.0.0.1, http://localhost, or http://*.stenci.la
// The origin 'file://' is sent when a connection is made from Electron (i.e Stencila Desktop)
if (origin) {
if (origin !== 'file://') {
let host = url.parse(origin).hostname
let match = host.match(/^((127\.0\.0\.1)|(localhost)|(([^.]+\.)?stenci\.la))$/)
if (!match) origin = null
}
}
// If an origin has been found and is authorized set CORS headers
// Without these headers browser XHR request get an error like:
// No 'Access-Control-Allow-Origin' header is present on the requested resource.
// Origin 'http://evil.hackers:4000' is therefore not allowed access.
if (origin) {
// 'Simple' requests (GET and POST XHR requests)
response.setHeader('Access-Control-Allow-Origin', origin)
// Allow sending cookies and other credentials
response.setHeader('Access-Control-Allow-Credentials', 'true')
// Pre-flighted requests by OPTIONS method (made before PUT, DELETE etc XHR requests and in other circumstances)
// get additional CORS headers
if (request.method === 'OPTIONS') {
// Allowable methods and headers
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
// According to MDN "The simple headers, Accept, Accept-Language, Content-Language, Content-Type are always available and
// don't need to be listed by this header." but I found it was necessary
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
// "how long the response to the preflight request can be cached for without sending another preflight request"
response.setHeader('Access-Control-Max-Age', '86400') // 24 hours
}
}
if (request.method === 'OPTIONS') {
// For preflighted CORS OPTIONS requests return an empty response with headers set
// (https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Preflighted_requests)
response.end()
} else {
let endpoint = this.route(request.method, uri.pathname, authorized)
if (endpoint) {
return new Promise((resolve, reject) => {
// Handle mock requests used during testing
if (request._setBody) resolve(JSON.stringify(request.body))
else {
body(request, (err, body) => {
if (err) reject(err)
else resolve(body)
})
}
}).then(body => {
let method = endpoint[0]
let params = endpoint.slice(1)
let data = body ? JSON.parse(body) : {}
return method.call(this, request, response, ...params, data)
}).catch(error => {
this.error500(request, response, error)
})
} else {
return this.error400(request, response)
}
}
}
/**
* Route a HTTP request
*
* @param {string} verb - The request's HTTP verb (aka. "method") eg. GET
* @param {string} path - The requested path
* @param {Boolean} authorized - Is the request authorized
* @return {array} - An array with first element being the method to call,
* and subsequent elements being the call arguments
*/
route (verb, path, authorized) {
// Public endpoints
if (path === '/') return [this.home]
if (path.substring(0, 8) === '/static/') return [this.statico, path.substring(8)]
if (path === '/manifest') return [this.manifest]
// Private endpoints for which authorization is necessary
if (!authorized) return [this.error403, `Authorization is required for ${verb} ${path}`]
let matches = path.match(/^\/([^!$]+)((!|\$)([^?]+))?.*$/)
if (matches) {
let id = matches[1]
let operator = matches[3]
let method = matches[4]
if (verb === 'POST' && id && !method) {
if (id.substring(0, 8) === 'environ/') return [this.startup, id.substring(8)]
else return [this.create, id]
} else if (verb === 'GET' && id && !method) {
return [this.get, id]
} else if (verb === 'PUT' && id && operator === '!' && method) {
return [this.call, id, method]
} else if (verb === 'DELETE' && id && !method) {
if (id.substring(0, 8) === 'environ/') return [this.shutdown, id.substring(8)]
else return [this.destroy, id]
}
}
return null
}
/**
* Handle a request to `home`
*/
home (request, response) {
return this.statico(request, response, 'index.html')
}
/**
* Handle a request for a static file
*/
statico (request, response, path_) {
return new Promise((resolve) => {
let staticPath = path.join(__dirname, '../../static')
let requestedPath = path.join(staticPath, url.parse(path_).pathname)
if (!pathIsInside(requestedPath, staticPath)) {
this.error403(request, response, path_)
resolve()
} else {
send(request, requestedPath)
.on('error', (err) => {
if (err.status === 404) this.error404(request, response, path_)
else this.error500(request, response, path_)
resolve()
})
.on('end', resolve)
.pipe(response)
}
})
}
/**
* Handle a request to `manifest`
*/
manifest (request, response) {
this._host.manifest().then(manifest => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(manifest))
})
}
/**
* Handle a request to startup an environment
*/
startup (request, response, type, options) {
return this._host.startup(type, options).then(result => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(result))
})
}
/**
* Handle a request to shutdown an environment
*/
shutdown (request, response, type, options) {
return this._host.shutdown(type, options).then(result => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(result))
})
}
/**
* Handle a request to create an instance
*/
create (request, response, type, options) {
return this._host.create(type, options).then(result => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(result.id))
})
}
/**
* Handle a request to get an instance
*/
get (request, response, name) {
return this._host.get(name).then(repr => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(repr))
})
}
/**
* Handle a request to call an instance method
*/
call (request, response, name, method, data) {
return this._host.call(name, method, data).then(result => {
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify(result))
})
}
/**
* Handle a request to destroy an instance
*/
destroy (request, response, name) {
return this._host.destroy(name).then(() => {
response.end()
})
}
/**
* General error handling
*/
error (request, response, status, error) {
return new Promise((resolve) => {
response.statusCode = status
let content
if (acceptsJson(request)) {
response.setHeader('Content-Type', 'application/json')
content = JSON.stringify(error)
} else {
content = error.error + ': ' + error.details
}
response.end(content)
resolve()
})
}
/**
* Specific error handling functions
*/
error400 (request, response, details) {
details = details || (request.method + ' ' + request.url)
return this.error(request, response, 400, {error: 'Bad request', details: details})
}
error403 (request, response, details) {
return this.error(request, response, 403, {error: 'Forbidden', details: details})
}
error404 (request, response, details) {
return this.error(request, response, 404, {error: 'Not found', details: details})
}
error500 (request, response, error) {
/* istanbul ignore next */
return this.error(request, response, 500, {error: 'Internal error', details: error ? error.stack : ''})
}
}
function acceptsJson (request) {
let accept = request.headers['accept'] || ''
return accept.match(/application\/json/)
}
module.exports = HostHttpServer