ares-ide
Version:
A browser-based code editor and UI designer for Enyo 2 projects
598 lines (538 loc) • 18 kB
JavaScript
/*jshint node: true, strict: false, globalstrict: false */
var fs = require("graceful-fs"),
path = require("path"),
express = require("express"),
util = require("util"),
createDomain = require('domain').create,
log = require('npmlog'),
http = require("http"),
mkdirp = require("mkdirp"),
CombinedStream = require('combined-stream'),
base64 = require('base64-stream'),
Busboy = require('busboy'),
Readable = require('stream').Readable,
HttpError = require("./httpError");
module.exports = ServiceBase;
/**
* Base object for Ares services
*
* @param {Object} config
* @property config {String} port requested IP port (0 for dynamic allocation, the default)
* @property config {String} pathname location after the service origin, defaults to '/'
* @property config {String} basename child class name (for tracing)
* @property config {Boolean} performCleanup clean temporary files & folders (default to true)
* @property service {Number} timeout HTTP timeout in ms (12000)
*
* @param {Function} next
* @param {Error} next err
* @param {Object} next service
* @property service {String} protocol in ['http', 'https']
* @property service {String} host IP address the server is bound to (useful in case of dynamic allocation)
* @property service {String} port the server is bound to (useful in case of dynamic allocation)
* @property service {String} origin consolidated string of protocol, host & port
* @property service {String} pathname to locat the service behind the origin
*
* @public
*/
function ServiceBase(config, next) {
config.timeout = config.timeout || (2*60*1000);
config.port = config.port || 0;
config.pathname = config.pathname || '/';
if (config.performCleanup === undefined) {
config.performCleanup = true;
}
if (config.level) {
log.level = config.level;
}
this.config = config;
log.info('ServiceBase()', "config:", this.config);
// express 3.x: app is not a server
this.app = express();
this.server = http.createServer(this.app);
this.server.setTimeout(config.timeout);
/*
* Middleware -- applied to every verbs
*/
if (log.level !== 'error' && log.level !== 'warn') {
this.app.use(express.logger('dev'));
}
/*
* Error Handling - Wrap exceptions in delayed handlers
*/
this.app.use(function _useDomain(req, res, next) {
log.silly("ServiceBase#_useDomain()");
var domain = createDomain();
domain.on('error', function(err) {
setImmediate(next, err);
domain.dispose();
});
domain.enter();
setImmediate(next);
});
this.app.use(this.allowLocalOnly.bind(this));
this.app.use(this.bodyParser.bind(this));
// Service-provides middle-wares
this.use();
/*
* verbs
*/
this.app.post('/config', (function(req, res , next) {
this.configure(req.body && req.body.config, function(err) {
if (err) {
next(err);
} else {
res.status(200).end();
}
});
}).bind(this));
this.route();
/*
* error handling: express-3.x: middleware with arity === 4 is
* detected as the global error handler
*/
this.app.use(this.errorHandler.bind(this));
// Send back the service location information (origin,
// protocol, host, port, pathname) to the creator, when port
// is bound
this.server.listen(config.port, "127.0.0.1", null /*backlog*/, (function() {
var tcpAddr = this.server.address();
setImmediate(next, null, {
protocol: 'http',
host: tcpAddr.address,
port: tcpAddr.port,
origin: "http://" + tcpAddr.address + ":"+ tcpAddr.port,
pathname: config.pathname
});
}).bind(this));
}
/**
* Make sane Express matching paths
* @protected
*/
ServiceBase.prototype.makeExpressRoute = function(path) {
return (this.config.pathname + path)
.replace(/\/+/g, "/") // compact "//" into "/"
.replace(/(\.\.)+/g, ""); // remove ".."
};
/**
* @param {Object} config
* @propery config {} basename
* @property config {int} maxDataSize used by https://npmjs.org/package/combined-stream
* @propery config {String} pathname
* @propery config {int} port
* @protected
*/
ServiceBase.prototype.configure = function(config, next) {
log.silly("ServiceBase#configure()", "old config:", this.config);
log.silly("ServiceBase#configure()", "inc config:", config);
util._extend(this.config, config);
log.verbose("ServiceBase#configure()", "new config:", this.config);
setImmediate(next);
};
/**
* Additionnal middlewares: 'this.app.use(xxx)'
* @protected
*/
ServiceBase.prototype.use = function(/*config, next*/) {
log.verbose('ServiceBase#use()', "skipping...");
};
/**
* Additionnal routes/verbs: 'this.app.get()', 'this.app.post()'
* @protected
*/
ServiceBase.prototype.route = function(/*config, next*/) {
log.verbose('ServiceBase#route()', "skipping...");
};
/**
* @protected
*/
ServiceBase.prototype.allowLocalOnly = function(req, res, next) {
log.silly("ServiceBase#authorize()");
if (req.connection.remoteAddress !== "127.0.0.1") {
setImmediate(next, new Error("Access denied from IP address "+req.connection.remoteAddress));
} else {
setImmediate(next);
}
};
/**
* @protected
*/
ServiceBase.prototype.setCookie = function(res, key, value) {
var exdate=new Date();
exdate.setDate(exdate.getDate() + 10 /*days*/);
var cookieOptions = {
domain: '127.0.0.1:' + this.config.port,
path: this.config.pathname,
httpOnly: true,
expires: exdate
//maxAge: 1000*3600 // 1 hour
};
res.cookie(key, value, cookieOptions);
log.info('ServiceBase#setCookie()', "Set-Cookie: " + key + ":", value || "");
};
/**
* Global error handler (arity === 4)
* @protected
*/
ServiceBase.prototype.errorHandler = function(err, req, res, next){
log.error("ServiceBase#errorHandler()", err.stack);
res.status(err.statusCode || 500);
res.contentType('txt'); // direct usage of 'text/plain' does not work
res.send(err.toString());
this.cleanSession(req, res, next);
};
/**
* @protected
*/
ServiceBase.prototype.cleanSession = function(req, res, next) {
log.verbose("ServiceBase#cleanSession()", 'nothing to do');
};
/**
* @protected
*/
ServiceBase.prototype.answerOk = function(req, res /*, next*/) {
log.verbose("ServiceBase#answerOk()", '200 OK');
res.status(200).send();
};
/**
* @protected
* @param {http.Request} req inbound HTTP request
* @property req {path} storeDir where to store the file parts of the request
* @param {http.Response} res outbound HTTP response
* @param {Function} next commonJS callback
* @see {ServiceBase._storeMultiPart}
*/
ServiceBase.prototype.store = function(req, res, next) {
log.verbose("ServiceBase#store()");
if (req.is('multipart/form-data')) {
this._storeMultipart(req, _storeOne, next);
} else {
setImmediate(next, new HttpError("Not a multipart request", 415 /*Unsupported Media Type*/));
return;
}
function _storeOne(file, next) {
var absPath = path.join(req.storeDir, file.name);
mkdirp(path.dirname(absPath), function(err) {
var out = fs.createWriteStream(absPath);
out.on('error', function(err) {
log.warn("ServiceBase#store#_storeOne()", "err:", err);
next(err);
});
out.on('close', function() {
log.silly("ServiceBase#store#_storeOne()", "wrote:", absPath);
next();
});
file.stream.pipe(out);
});
}
};
/**
* @see {FsBase._putMultipart}
*/
ServiceBase.prototype._storeMultipart = function(req, storeOne, next) {
log.verbose("ServiceBase#_storeMultipart()");
this.receiveFormData(req, _receiveFile, _receiveField, next);
function _receiveFile(fieldName, fieldValue, next, fileName, encoding) {
log.silly("ServiceBase#_storeMultipart#_receiveFile()", "fieldName:", fieldName, "fileName:", fileName, "encoding:", encoding);
if (fieldName === "file" || fieldName === "blob" || fieldName === ".") {
fieldName = undefined;
}
if (fileName === "file" || fileName === "blob" || fileName === ".") {
fileName = undefined;
}
var file = {
name: fieldName || fileName,
stream: encoding === 'base64' ? fieldValue.pipe(base64.decode()) : fieldValue
};
storeOne(file, next);
}
function _receiveField(fieldName, fieldValue) {
log.warn("ServiceBase#_storeMultipart#_receiveField()", "unexpected field:",fieldName , "fieldValue:", fieldValue );
}
};
/**
* @protected
* @param {http.Request} req inbound HTTP request
* @param {http.Response} res outbound HTTP response
* @property res {path} file file to be sent back as the body of the response (Content-Type is automatic)
* @param {Function} next commonJS callback
*/
ServiceBase.prototype.returnFile = function(req, res, next) {
res.status(200).sendfile(res.file);
delete res.file;
setImmediate(next);
};
/**
* @protected
* @param {http.Request} req inbound HTTP request
* @param {http.Response} res outbound HTTP response
* @property res {Object} body
* @property res {String} contentType force 'Content-Type' response header (otherwise set automatically by express)
* @param {Function} next commonJS callback
*/
ServiceBase.prototype.returnBody = function(req, res, next) {
if (res.contentType) {
// Otherwise count on express to detect the
// content-type
res.header('content-type', res.contentType);
}
res.status(200).send(res.body);
delete res.body;
delete res.contentType;
setImmediate(next);
};
/**
* @param {Array} parts
* @item parts {Object} part
* @property part {String} [filename] name to put in the FormData
* @property part {ReadableStream} [stream] input stream to use for the bits
* @property part {Buffer} [buffer] input buffer to use for the bits
* @protected
*
* @see http://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.2
* @see http://www.w3.org/Protocols/rfc1341/5_Content-Transfer-Encoding.html
*/
ServiceBase.prototype.returnFormData = function(parts, res, next) {
if (!Array.isArray(parts) || parts.length < 1) {
setImmediate(next, new Error("Invalid parameters: cannot return a multipart/form-data of nothing"));
return;
}
log.verbose("ServiceBase#returnFormData()", parts.length, "parts");
var combinedStream;
try {
// Build the multipart/formdata
var FORM_DATA_LINE_BREAK = '\r\n',
boundary = _generateBoundary();
var mode;
combinedStream = CombinedStream.create({
maxDataSize: this.config.maxDataSize || 15*1024*1024 /*15 MB*/
});
parts.forEach(function(part) {
// Adding part header
if (!part.name) {
throw new HttpError(503, "Invalid (missing name) part:"+ util.inspect(part, {level: 2}));
} else {
combinedStream.append(_getPartHeader(part.name));
}
// Adding data
if (part.path) {
mode = "path";
combinedStream.append(function(append) {
var stream = fs.createReadStream(part.path);
stream.on('error', function(err) {
log.warn("ServiceBase#returnFormData()", "part:", part.name, "(" + mode + ")", "err:", err);
next(err);
});
append(stream.pipe(base64.encode()));
});
} else if (part.stream) {
mode = "stream";
part.stream.on('error', function(err) {
log.warn("ServiceBase#returnFormData()", "part:", part.name, "(" + mode + ")", "err:", err);
next(err);
});
combinedStream.append(part.stream.pipe(base64.encode()));
} else if (part.buffer) {
mode = "buffer";
combinedStream.append(function(append) {
append(part.buffer.toString('base64'));
});
} else {
log.warn("ServiceBase#returnFormData()", "Invalid part:", part);
throw new HttpError(503, "Invalid part:"+ util.inspect(part, {level: 2}));
}
log.silly("ServiceBase#returnFormData()", "part:", part.name, "(" + mode + ")");
// Adding part footer
combinedStream.append(function(append) {
log.silly("ServiceBase#returnFormData()", "end-of-part:", part.name);
append(_getPartFooter());
});
});
// Adding last footer
combinedStream.append(function(append) {
append(_getLastPartFooter());
});
} catch(err) {
setImmediate(next, err);
return;
}
// Send the files back as a multipart/form-data
res.status(200);
res.header('Content-Type', _getContentTypeHeader());
res.header('X-Content-Type', _getContentTypeHeader());
combinedStream.on('error', next);
combinedStream.on('end', function() {
log.silly("ServiceBase#returnFormData()", "Streaming completed");
next();
});
combinedStream.pipe(res);
function _generateBoundary() {
// This generates a 50 character boundary similar to those used by Firefox.
// They are optimized for boyer-moore parsing.
var boundary = '--------------------------';
for (var i = 0; i < 24; i++) {
boundary += Math.floor(Math.random() * 10).toString(16);
}
return boundary;
}
function _getContentTypeHeader() {
return 'multipart/form-data; boundary=' + boundary;
}
function _getPartHeader(filename) {
var header = '--' + boundary + FORM_DATA_LINE_BREAK;
header += 'Content-Disposition: form-data; name="file"';
header += '; filename="' + filename + '"' + FORM_DATA_LINE_BREAK;
// 'Content-Transfer-Encoding: base64' require
// 76-columns data, to not break
// `connect.bodyParser()`... so we use our own
// `ServiceBase.bodyParser()`.
header += 'Content-Type: application/octet-stream' + FORM_DATA_LINE_BREAK;
header += 'Content-Transfer-Encoding: base64' + FORM_DATA_LINE_BREAK;
header += FORM_DATA_LINE_BREAK;
return header;
}
function _getPartFooter() {
return FORM_DATA_LINE_BREAK;
}
function _getLastPartFooter() {
return '--' + boundary + '--';
}
};
ServiceBase.prototype.bodyParser = function(req, res, next) {
if (req.is("application/json")) {
this.receiveJson(req, res, next);
} else if (req.is("application/x-www-form-urlencoded")) {
this.receiveWebForm(req, res, next);
} else {
setImmediate(next);
}
};
ServiceBase.prototype.receiveJson = function(req, res, next) {
var buf = '';
req.setEncoding('utf8');
req.on('data', function(chunk){
buf += chunk;
});
req.on('end', function(){
if (0 === buf.length) {
return next(new HttpError(400, "Invalid JSON, empty body"));
}
try {
req.body = JSON.parse(buf);
} catch (err){
return next(new HttpError(400, "Invalid JSON: " + err.toString()));
}
next();
});
};
/**
* Parse the multipart/form-data in the incoming request
* @param {http.Request} req
* @param {Function} receiveFile
* @param receiveFile {String} name might be "file" or the actual part file name
* @param receiveFile {stream.Readable} file
* @param receiveFile {Function} next commonJS callback, to be called when the file was read completelly
* @param receiveFile {String} [filename] the actual part file name, when present
* @param {Function} receiveField
* @param receiveField {String} fieldname
* @param receiveField {String} fieldvalue
* @param {Function} next commonJS callback
* @param next {Error} err falsy in case of success
* @param next {Integer} nfiles number of files received
* @param next {Integer} nfields number of fields set in {req.body}
*/
ServiceBase.prototype.receiveFormData = function(req, receiveFile, receiveField, next) {
log.verbose("ServiceBase#receiveFormData()");
var infiles = 0, outfiles = 0, nfields = 0;
var parsed = false;
var busboy = new Busboy({headers: req.headers});
busboy.on('file', function(fieldName, fieldValue, fileName, encoding) {
infiles++;
log.silly("ServiceBase#receiveFormData()", "fieldName:", fieldName, ", fileName:", fileName, "encoding:", encoding);
var file = new Readable().wrap(fieldValue);
receiveFile(fieldName, file, _receivedPart, fileName, encoding);
});
busboy.on('field', function(fieldname, val, valTruncated, keyTruncated) {
++nfields;
log.silly("ServiceBase#receiveFormData()", "field", fieldname, "=", val);
receiveField(fieldname, val);
});
busboy.once('end', function() {
log.silly("ServiceBase#receiveFormData()", "parsing complete, infiles:", infiles);
parsed = true;
if (infiles === 0) {
next();
}
});
log.silly("ServiceBase#receiveFormData()", "parsing started");
req.on('error', function(err) {
log.warn("ServiceBase#receiveFormData()", "req.err:", err);
next(err);
});
busboy.on('error', function(err) {
log.warn("ServiceBase#receiveFormData()", "busboy.err:", err);
next(err);
});
req.pipe(busboy);
function _receivedPart(err) {
outfiles++;
log.silly("ServiceBase#receiveFormData_receivedPart()", outfiles + "/" + infiles + " file parts processed");
if (parsed && infiles === outfiles) {
log.verbose("ServiceBase#receiveFormData#_receivedPart()", "received", outfiles, "parts");
setImmediate(next, null, outfiles, nfields);
}
}
};
/**
* Parse the application/x-www-form-urlencoded in the incoming request
* @param {http.Request} req
* @param {Function} receiveField
* @param receiveField {String} fieldname
* @param receiveField {String} fieldvalue
* @param {Function} next commonJS callback
*/
ServiceBase.prototype.receiveWebForm = function(req, res, next) {
var nfields = 0;
var busboy = new Busboy({headers: req.headers});
req.body = req.body || {};
busboy.on('field', function(fieldname, val, valTruncated, keyTruncated) {
++nfields;
log.silly("ServiceBase#receiveWebForm()", "field", fieldname, "=", val);
req.body[fieldname] = val;
});
busboy.once('end', function() {
log.verbose("ServiceBase#receiveWebForm()", "parsed " + nfields + " fields");
next();
});
busboy.on('error', function(err) {
log.warn("ServiceBase#receiveWebForm()", "busboy.err:", err);
next(err);
});
log.verbose("ServiceBase#receiveWebForm()", "parsing started");
req.pipe(busboy);
};
/**
* Terminates express server & clean-up the plate
* @protected
*/
ServiceBase.prototype.quit = function(next) {
log.info('ServiceBase#quit()');
this.server.close();
if (this.config.performCleanup) {
this.cleanProcess(next);
} else {
setImmediate(next);
}
};
/**
* @protected
*/
ServiceBase.prototype.cleanProcess = function() {
log.verbose('ServiceBase#cleanProcess()');
};
/**
* @protected
*/
ServiceBase.prototype.onExit = function() {
log.verbose('ServiceBase#onExit()');
};