src/node-httpserver.js
// SAGE2 is available for use under the SAGE2 Software License
//
// University of Illinois at Chicago's Electronic Visualization Laboratory (EVL)
// and University of Hawai'i at Manoa's Laboratory for Advanced Visualization and
// Applications (LAVA)
//
// See full text, terms and conditions in the LICENSE.txt included file
//
// Copyright (c) 2014
/**
* SAGE2 HTTP handlers
*
* @module server
* @submodule httpserver
* @requires node-utils
*/
// require variables to be declared
"use strict";
// builtins
var fs = require('fs');
var path = require('path');
var url = require('url');
var mime = require('mime');
var zlib = require('zlib'); // to enable HTTP compression
// Using the debug package to track HTTP request
// to see request: env DEBUG=sage2http node server.js ....
var debug = require('debug')('sage2http');
// External package to clean up URL requests
var normalizeURL = require('normalizeurl');
// SAGE2 own modules
var sageutils = require('../src/node-utils'); // provides utility functions
var generateSW = require('../generate-service-worker.js');
/**
* SAGE HTTP request handlers for GET and POST
*
* @class HttpServer
* @constructor
* @param publicDirectory {String} folder to expose to the server
*/
function HttpServer(publicDirectory) {
this.publicDirectory = publicDirectory;
this.getFuncs = {};
this.postFuncs = {};
this.onrequest = this.onreq.bind(this);
// Generate the service worker for caching
generateSW();
}
/**
* Given a request, will attempt to detect all associated cookies.
*
* @method detectCookies
* @param request {Object} the request that came from a client
* @return {Object} containing the list of cookies in string format
*/
function detectCookies(request) {
var cookieList = [];
var allCookies = request.headers.cookie;
var i = 0;
if (allCookies != null) {
while (allCookies.indexOf(';') !== -1) {
cookieList.push(allCookies.substring(0, allCookies.indexOf(';')));
cookieList[i] = cookieList[i].trim();
allCookies = allCookies.substring(allCookies.indexOf(';') + 1);
i++;
} // end while there is a ;
cookieList.push(allCookies.trim());
}
return cookieList;
}
/**
* Handle a page not found (404)
*
* @method notfound
* @param res {Object} response
*/
HttpServer.prototype.notfound = function(res) {
var header = this.buildHeader();
// Do not allow iframe
header["X-Frame-Options"] = "DENY";
res.writeHead(404, header);
res.write('<meta http-equiv="refresh" content="5;url=/index.html">');
res.write('<h1>SAGE2 error</h1>Invalid request\n');
res.write('<br><br><br>\n');
res.write('<b><a href=/index.html>SAGE2 main page</a></b>\n');
res.end();
};
/**
* Handle a HTTP redirect
*
* @method redirect
* @param res {Object} response
* @param aurl {String} destination URL
*/
HttpServer.prototype.redirect = function(res, aurl) {
var header = this.buildHeader();
// Do not allow iframe
header["X-Frame-Options"] = "DENY";
// 301 HTTP code for redirect: Moved Permanently
// causes issue with caching and cookies
// 302 HTTP code for found: redirect
header.Location = aurl;
res.writeHead(302, header);
res.end();
};
var hpkpPin1 = (function() {
var pin;
return function() {
if (!pin) {
pin = fs.readFileSync(path.join("keys", "pin1.sha256"), {encoding: 'utf8'});
pin = pin.trim();
// console.log('PIN1', pin);
}
return pin;
};
}());
var hpkpPin2 = (function() {
var pin;
return function() {
if (!pin) {
pin = fs.readFileSync(path.join("keys", "pin2.sha256"), {encoding: 'utf8'});
pin = pin.trim();
// console.log('PIN2', pin);
}
return pin;
};
}());
/**
* Build an HTTP header object
*
* @method buildHeader
* @return {Object} an object containig common HTTP header values
*/
HttpServer.prototype.buildHeader = function() {
// Get the site configuration, from server.js
var cfg = global.config;
// Build the header object
var header = {};
// Default datatype of the response
header["Content-Type"] = "text/html; charset=utf-8";
// The X-Frame-Options header can be used to to indicate whether a browser is allowed
// to render a page within an <iframe> element or not. This is helpful to prevent clickjacking
// attacks by ensuring your content is not embedded within other sites.
// See more here: https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options.
// "SAMEORIGIN" or "DENY" for instance
header["X-Frame-Options"] = "SAMEORIGIN";
// This header enables the Cross-site scripting (XSS) filter built into most recent web browsers.
// It's usually enabled by default anyway, so the role of this header is to re-enable the filter
// for this particular website if it was disabled by the user.
// This header is supported in IE 8+, and in Chrome.
header["X-XSS-Protection"] = "1; mode=block";
// The only defined value, "nosniff", prevents Internet Explorer and Google Chrome from MIME-sniffing
// a response away from the declared content-type. This also applies to Google Chrome, when downloading
// extensions. This reduces exposure to drive-by download attacks and sites serving user uploaded content
// that, by clever naming, could be treated by MSIE as executable or dynamic HTML files.
header["X-Content-Type-Options"] = "nosniff";
// HTTP Strict Transport Security (HSTS) is an opt-in security enhancement
// Once a supported browser receives this header that browser will prevent any
// communications from being sent over HTTP to the specified domain
// and will instead send all communications over HTTPS.
// Here using a long (1 year) max-age
if (cfg.security && sageutils.isTrue(cfg.security.enableHSTS)) {
header["Strict-Transport-Security"] = "max-age=31536000";
}
// Instead of blindly trusting everything that a server delivers, Content-Security-Policy defines
// the HTTP header that allows you to create a whitelist of sources of trusted content,
// and instructs the browser to only execute or render resources from those sources.
// Even if an attacker can find a hole through which to inject script, the script won’t match
// the whitelist, and therefore won’t be executed.
// default-src 'none' -> default policy that blocks absolutely everything
if (cfg.security && sageutils.isTrue(cfg.security.enableCSP)) {
// Pretty open
header["Content-Security-Policy"] = "default-src 'none';" +
" plugin-types image/svg+xml;" +
" object-src 'self';" +
" child-src 'self' blob:;" +
" connect-src *;" +
" font-src 'self' fonts.gstatic.com;" +
" form-action 'self';" +
" img-src * data: blob:;" +
" media-src 'self' blob:;" +
" style-src 'self' 'unsafe-inline' fonts.googleapis.com;" +
" script-src * 'unsafe-eval';";
}
// More secure
// header["Content-Security-Policy"] = "default-src 'none';" +
// " plugin-types image/svg+xml;" +
// " object-src 'self';" +
// " child-src 'self' blob:;" +
// " connect-src 'self' wss: ws: https://query.yahooapis.com https://data.cityofchicago.org https://lyra.evl.uic.edu:9000;" +
// " font-src 'self';" +
// " form-action 'self';" +
// // " img-src 'self' data: http://openweathermap.org a.tile.openstreetmap.org b.tile.openstreetmap.org " +
// // "c.tile.openstreetmap.org http://www.webglearth.com http://server.arcgisonline.com http://radar.weather.gov " +
// // "http://cdn.abclocal.go.com http://www.glerl.noaa.gov " +
// // "https://lyra.evl.uic.edu:9000 https://maps.gstatic.com https://maps.googleapis.com https://khms0.googleapis.com " +
// // "https://khms1.googleapis.com https://khms2.googleapis.com https://csi.gstatic.com;" +
// " img-src *;" +
// " media-src 'self';" +
// " style-src 'self' 'unsafe-inline';" +
// " script-src 'self' http://www.webglearth.com https://maps.googleapis.com 'unsafe-eval';";
// HTTP PUBLIC KEY PINNING (HPKP)
// Key pinning is a trust-on-first-use (TOFU) mechanism.
// The first time a browser connects to a host it lacks the the information necessary to perform
// "pin validation" so it will not be able to detect and thwart a MITM attack.
// This feature only allows detection of these kinds of attacks after the first connection.
if (cfg.security && sageutils.isTrue(cfg.security.enableHPKP)) {
// 30 days expirations
header["Public-Key-Pins"] = "pin-sha256=\"" + hpkpPin1() +
"\"; pin-sha256=\"" + hpkpPin2() +
"\"; max-age=2592000; includeSubDomains";
}
return header;
};
/**
* Main router and trigger the GET and POST handlers
*
* @method onreq
* @param req {Object} request
* @param res {Object} response
*/
HttpServer.prototype.onreq = function(req, res) {
var i;
var _this = this;
if (req.method === "GET") {
var reqURL = url.parse(req.url);
// Remove the bad HTML, like script
var getName = sageutils.sanitizedURL(reqURL.pathname);
// Remove the bad path, like ..
getName = normalizeURL(getName);
// Check the HTTP GET handlers
if (getName in this.getFuncs) {
this.getFuncs[getName](req, res);
return;
}
// redirect root path to index.html
if (getName === "/") {
this.redirect(res, "index.html");
return;
}
// Get the actual path of the file
var pathname;
// //////////////////////
// Routes
// //////////////////////
if (getName.lastIndexOf('/images/', 0) === 0 ||
getName.lastIndexOf('/shaders/', 0) === 0 ||
getName.lastIndexOf('/css/', 0) === 0 ||
getName.lastIndexOf('/lib/', 0) === 0 ||
getName.lastIndexOf('/src/', 0) === 0) {
// Sources folders: bypass the search
pathname = path.join(this.publicDirectory, getName);
} else {
// Then search in the various media folders
// pathname: result of the search
pathname = null;
// walk through the list of folders
for (var f in global.mediaFolders) {
// Get the folder object
var folder = global.mediaFolders[f];
// Look for the folder url in the request
var pubdir = getName.split(folder.url);
if (pubdir.length === 2) {
// convert the URL into a path
var suburl = path.join('.', pubdir[1]);
// pathname = url.resolve(folder.path, suburl);
pathname = path.join(folder.path, suburl);
break;
}
}
// if everything fails, look in the default public folder
if (!pathname) {
pathname = path.join(this.publicDirectory, getName);
}
}
// Decode the misc characters in the URL
pathname = decodeURIComponent(pathname);
// Converting to an actual path
pathname = path.resolve(pathname);
// Track requests and responses
debug('request', (req.connection.encrypted ? 'https' : 'http') + '://' + req.headers.host + req.url);
debug('response', pathname);
// //////////////////////
// Are we trying to session management
// //////////////////////
if (global.__SESSION_ID) {
// if the request is for an HTML page (no security check otherwise)
// and it is not session.html
if (path.extname(pathname) === ".html" &&
(getName.indexOf("/session.html") !== 0)) {
// Get the cookies from the request header
var cookieList = detectCookies(req);
// Go through the list of cookies
var sessionMatch = false;
for (i = 0; i < cookieList.length; i++) {
if (cookieList[i].indexOf("session=") !== -1) {
// We found it
if (cookieList[i].indexOf(global.__SESSION_ID) !== -1) {
sessionMatch = true;
}
}
}
// If no match, go back to password page
if (!sessionMatch) {
this.redirect(res, "/session.html?page=" + req.url.substring(1));
}
}
}
// redirect a folder path to its containing index.html
if (sageutils.fileExists(pathname)) {
var stats = fs.lstatSync(pathname);
if (stats.isDirectory()) {
this.redirect(res, getName + "/index.html");
return;
}
// Build a default header object
var header = this.buildHeader();
if (path.extname(pathname) === ".html") {
if (pathname.endsWith("public/index.html")) {
// Allow embedding the UI page
delete header['X-Frame-Options'];
} else {
// Do not allow iframe
header['X-Frame-Options'] = 'DENY';
}
} else {
// not needed for images and such
delete header["X-XSS-Protection"];
delete header['X-Frame-Options'];
}
header['Access-Control-Allow-Headers'] = 'Range';
header['Access-Control-Expose-Headers'] = 'Accept-Ranges, Content-Encoding, Content-Length, Content-Range';
if (req.headers.origin !== undefined) {
header['Access-Control-Allow-Origin' ] = req.headers.origin;
header['Access-Control-Allow-Methods'] = 'GET';
header['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept';
header['Access-Control-Allow-Credentials'] = true;
}
if (getName.match(/^\/(src|lib|images|css)\/.+/)) {
// cache for 1 week for SAGE2 core files
// header['Cache-Control'] = 'public, max-age=604800';
// No persistent copy - must check
header['Cache-Control'] = 'no-store, must-revalidate, max-age=604800';
} else {
// No caching at all
header['Cache-Control'] = 'no-cache, no-store, must-revalidate';
/* eslint-disable */
header['Pragma'] = 'no-cache';
header['Expires'] = '0';
/* eslint-enable */
}
// Useful Cache-Control response headers include:
// max-age=[seconds] — specifies the maximum amount of time that a representation will be considered fresh.
// s-maxage=[seconds] — similar to max-age, except that it only applies to shared (e.g., proxy) caches.
// public — marks authenticated responses as cacheable;
// private — allows caches that are specific to one user (e.g., in a browser) to store the response
// no-cache — forces caches to submit the request to the origin server for validation before releasing
// a cached copy, every time.
// no-store — instructs caches not to keep a copy of the representation under any conditions.
// must-revalidate — tells caches that they must obey any freshness information you give them about a representation.
// proxy-revalidate — similar to must-revalidate, except that it only applies to proxy caches.
//
// For example:
// Cache-Control: max-age=3600, must-revalidate
//
// Set the mime type
var fileMime = mime.getType(pathname);
var charFile;
if (fileMime === "image/svg+xml" || fileMime === "application/manifest+json") {
charFile = "UTF-8";
}
if (charFile) {
header["Content-Type"] = fileMime + "; charset=" + charFile;
} else {
header["Content-Type"] = fileMime;
}
// Get the file size from the 'stat' system call
var total = stats.size;
if (typeof req.headers.range !== 'undefined') {
// Parse the range request from the HTTP header
var range = req.headers.range;
var parts = range.replace(/bytes=/, "").split("-");
var partialstart = parts[0];
var partialend = parts[1];
var start = parseInt(partialstart, 10);
var end = partialend ? parseInt(partialend, 10) : total - 1;
var chunksize = (end - start) + 1;
// Set the range into the HTPP header for the response
header["Content-Range"] = "bytes " + start + "-" + end + "/" + total;
header["Accept-Ranges"] = "bytes";
header["Content-Length"] = chunksize;
// Write the HTTP header, 206 Partial Content
res.writeHead(206, header);
// Read part of the file
// This line opens the file as a readable stream
let readStream = fs.createReadStream(pathname, {start: start, end: end});
// This will wait until we know the readable stream is actually valid before piping
readStream.on('open', function () {
// This just pipes the read stream to the response object
readStream.pipe(res);
});
// This catches any errors that happen while creating the readable stream
readStream.on('error', function(err) {
res.end(err);
});
} else {
// Open the file as a stream
let readStream = fs.createReadStream(pathname);
// array of allowed compression file types
var compressExtensions = ['.html', '.json', '.js', '.css', '.txt', '.svg', '.xml', '.md'];
if (compressExtensions.indexOf(path.extname(pathname)) === -1) {
// Do not compress, just set file size
header["Content-Length"] = total;
res.writeHead(200, header);
readStream.on('open', function () {
readStream.pipe(res);
});
readStream.on('end', function() {
});
readStream.on('close', function() {
});
readStream.on('error', function(err) {
res.end(err);
});
} else {
// Check for allowed compression
var acceptEncoding = req.headers['accept-encoding'] || '';
if (acceptEncoding.match(/gzip/)) {
// Set the encoding to gzip
header["Content-Encoding"] = 'gzip';
// Write the HTTP response header
res.writeHead(200, header);
// Pipe the file input onto the HTTP response
readStream.on('open', function () {
readStream.pipe(zlib.createGzip()).pipe(res);
});
readStream.on('error', function(err) {
res.end(err);
});
} else if (acceptEncoding.match(/deflate/)) {
// Set the encoding to deflate
header["Content-Encoding"] = 'deflate';
res.writeHead(200, header);
readStream.on('open', function () {
readStream.pipe(zlib.createDeflate()).pipe(res);
});
readStream.on('error', function(err) {
res.end(err);
});
} else {
// No HTTP compression, just set file size
header["Content-Length"] = total;
res.writeHead(200, header);
readStream.on('open', function () {
readStream.pipe(res);
});
readStream.on('error', function(err) {
res.end(err);
});
}
}
}
} else {
// redirect to index page
// this.redirect(res, "/");
// File not found: 404 HTTP error
this.notfound(res);
return;
}
} else if (req.method === "POST") {
var postName = sageutils.sanitizedURL(url.parse(req.url).pathname);
if (postName in this.postFuncs) {
this.postFuncs[postName](req, res);
return;
}
} else if (req.method === "PUT") {
// Need some authentication / security here
var putName = sageutils.sanitizedURL(url.parse(req.url).pathname);
// Remove the first / if there
if (putName[0] === '/') {
putName = putName.slice(1);
}
var fileLength = 0;
var filename = path.join(this.publicDirectory, "uploads", "tmp", putName);
var wstream = fs.createWriteStream(filename);
wstream.on('finish', function() {
// stream closed
sageutils.log('PUT', 'File written', putName, fileLength, 'bytes');
});
wstream.on('error', function() {
// Error during write
sageutils.log('PUT', 'Error during write for', putName);
});
// Getting data
req.on('data', function(chunk) {
// Write into output stream
wstream.write(chunk);
fileLength += chunk.length;
});
// Data no more
req.on('end', function() {
// No more data
sageutils.log('PUT', 'Received:', filename, putName, fileLength, 'bytes');
// Close the write stream
wstream.end();
// empty 200 OK response for now
var header = _this.buildHeader();
header["Content-Type"] = "text/html";
res.writeHead(200, "OK", header);
res.end();
});
}
};
/**
* Add a HTTP GET handler (i.e. route)
*
* @method httpGET
* @param name {String} matching URL name (i.e. /config)
* @param callback {Function} processing function
*/
HttpServer.prototype.httpGET = function(name, callback) {
this.getFuncs[name] = callback;
};
/**
* Add a HTTP POST handler (i.e. route)
*
* @method httpPOST
* @param name {String} matching URL name (i.e. /upload)
* @param callback {Function} processing function
*/
HttpServer.prototype.httpPOST = function(name, callback) {
this.postFuncs[name] = callback;
};
module.exports = HttpServer;