@teclone/r-server
Version:
A lightweight, extensible web-server with inbuilt routing-engine, static file server, file upload handler, request body parser, middleware support and lots more
431 lines (402 loc) • 16.1 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var _classCallCheck = require('@babel/runtime/helpers/classCallCheck');
var _createClass = require('@babel/runtime/helpers/createClass');
var _defineProperty = require('@babel/runtime/helpers/defineProperty');
var crypto = require('crypto');
var fs = require('fs');
var path = require('path');
var utils = require('@teclone/utils');
var mime = require('mime-types');
var Constants = require('./Constants');
var Utils = require('./Utils');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
function _interopNamespace(e) {
if (e && e.__esModule) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n["default"] = e;
return Object.freeze(n);
}
var _classCallCheck__default = /*#__PURE__*/_interopDefaultLegacy(_classCallCheck);
var _createClass__default = /*#__PURE__*/_interopDefaultLegacy(_createClass);
var _defineProperty__default = /*#__PURE__*/_interopDefaultLegacy(_defineProperty);
var crypto__namespace = /*#__PURE__*/_interopNamespace(crypto);
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
var path__namespace = /*#__PURE__*/_interopNamespace(path);
var mime__default = /*#__PURE__*/_interopDefaultLegacy(mime);
function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
var FileServer = /*#__PURE__*/function () {
function FileServer(rootDir, config) {
_classCallCheck__default["default"](this, FileServer);
_defineProperty__default["default"](this, "config", void 0);
_defineProperty__default["default"](this, "rootDir", void 0);
this.rootDir = rootDir;
this.config = config;
}
/**
* streams a file to client
*/
_createClass__default["default"](FileServer, [{
key: "streamFile",
value: function streamFile(filePath, response, status, headers, options) {
var readStream = fs__namespace.createReadStream(filePath, options);
response.status(status).setHeaders(headers);
return new Promise(function (resolve, reject) {
readStream.on('error', function (err) {
return reject(err);
});
readStream.on('end', function () {
return resolve(true);
});
readStream.pipe(response, {
end: false
});
}).then(function () {
return response.end();
})["catch"](function (err) {
readStream.close();
return Utils.handleError(err, response);
});
}
/**
* validates range request content.
* @see https://tools.ietf.org/html/rfc7233
*/
}, {
key: "validateRangeRequest",
value: function validateRangeRequest(headers, eTag, lastModified, fileSize) {
var result = {
isMultiRange: false,
statusCode: 206,
ranges: []
};
var ifRange = headers['if-range'];
//check if we should send everything
if (ifRange && ifRange !== eTag) {
result.statusCode = 200;
result.ranges.push({
start: 0,
end: fileSize - 1,
length: fileSize
});
return result;
}
//we are not sending everything
var ranges = headers['range'].replace(/^\s*[^=]*=\s*/, '').split(/,\s*/);
result.isMultiRange = ranges.length > 0;
var _iterator = _createForOfIteratorHelper(ranges),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var range = _step.value;
var start = 0;
var end = fileSize - 1;
//suffix-byte-range-spec
if (/^-(\d+)$/.test(range)) {
var suffixLength = Number.parseInt(RegExp.$1);
if (suffixLength === 0) {
continue;
} else if (suffixLength < fileSize) {
start = fileSize - suffixLength;
}
}
//byte-range-spec
else if (/^(\d+)-(\d+)?$/.test(range)) {
var first = Number.parseInt(RegExp.$1);
var last = RegExp.$2 ? Number.parseInt(RegExp.$2) : end;
if (first >= fileSize || last < first) {
continue;
} else {
start = first;
if (last < end) {
end = last;
}
}
} else {
continue;
}
result.ranges.push({
start: start,
end: end,
length: end - start + 1
});
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return result;
}
/**
* check if file is modified
*/
}, {
key: "doesClientNeedContent",
value: function doesClientNeedContent(headers, contentTag, contentLastModified) {
if (!contentTag && !contentLastModified) {
return true;
}
if (!headers['if-none-match'] && !headers['if-match'] && !headers['if-modified-since'] && !headers['if-unmodified-since']) {
return true;
}
// check if match conditions
if (headers['if-match']) {
return contentTag === headers['if-match'];
}
if (headers['if-none-match']) {
return contentTag !== headers['if-none-match'];
}
if (headers['if-modified-since']) {
return !contentLastModified || contentLastModified > new Date(headers['if-modified-since']);
}
if (headers['if-unmodified-since']) {
return !contentLastModified || contentLastModified <= new Date(headers['if-unmodified-since']);
}
return true;
}
/**
* computes and returns a files eTag
*/
}, {
key: "computeETag",
value: function computeETag(fileMTime) {
var length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 16;
var hash = crypto__namespace.createHash('sha256');
hash.update(fileMTime.toString());
return hash.digest('hex').substring(0, length);
}
/**
* returns default response headers
*/
}, {
key: "getFileDefaultHeaders",
value: function getFileDefaultHeaders(filePath) {
var stat = fs__namespace.statSync(filePath);
var eTag = this.computeETag(stat.mtime);
var lastModified = stat.mtime;
// it is important to remove milliseconds
// to match date time format of last-modified header
lastModified.setUTCMilliseconds(0);
return {
lastModified: lastModified,
eTag: eTag,
fileSize: stat.size,
headers: {
'Content-Type': mime__default["default"].lookup(path__namespace.parse(filePath).ext.substring(1)) || 'application/octet-stream',
'Last-Modified': lastModified.toUTCString(),
'Content-Length': stat.size.toString(),
ETag: eTag,
'Cache-Control': this.config.cacheControl
}
};
}
/**
* process file
*/
}, {
key: "process",
value: function process(filePath, requestMethod, requestHeaders, response) {
requestMethod = requestMethod.toLowerCase();
if (requestMethod === 'options') {
return response.status(200).setHeaders({
Allow: Constants.ALLOWED_METHODS.join(',')
}).end();
}
var _this$getFileDefaultH = this.getFileDefaultHeaders(filePath),
resHeaders = _this$getFileDefaultH.headers,
eTag = _this$getFileDefaultH.eTag,
lastModified = _this$getFileDefaultH.lastModified,
fileSize = _this$getFileDefaultH.fileSize;
if (requestMethod === 'head') {
resHeaders['Accept-Ranges'] = 'bytes';
return response.status(200).setHeaders(resHeaders).end();
}
//if it is not a range request, negotiate content
if (utils.isUndefined(requestHeaders['range'])) {
if (!this.doesClientNeedContent(requestHeaders, eTag, lastModified)) {
return response.status(304).end();
} else {
return this.streamFile(filePath, response, 200, resHeaders);
}
} else {
//we are dealing with range request
var _this$validateRangeRe = this.validateRangeRequest(requestHeaders, eTag, lastModified, fileSize),
statusCode = _this$validateRangeRe.statusCode,
ranges = _this$validateRangeRe.ranges;
//we are sending everything
if (statusCode === 200) {
return this.streamFile(filePath, response, 200, resHeaders);
}
delete resHeaders['Content-Disposition'];
//range request is not satisfiable
if (ranges.length === 0) {
return response.status(416).setHeaders({
'Content-Range': "bytes */".concat(fileSize)
}).end();
}
//single range request
if (ranges.length === 1) {
var _ranges$ = ranges[0],
start = _ranges$.start,
end = _ranges$.end,
length = _ranges$.length;
resHeaders['Content-Length'] = length.toString();
resHeaders['Content-Range'] = "bytes ".concat(start, "-").concat(end, "/").concat(fileSize);
return this.streamFile(filePath, response, 206, resHeaders, {
start: start,
end: end
});
}
//multi range request. for now, we dont support multipart range. send everything
resHeaders['Content-Length'] = fileSize.toString();
resHeaders['Content-Range'] = "bytes ".concat(0, "-", fileSize - 1, "/").concat(fileSize);
return this.streamFile(filePath, response, 206, resHeaders);
}
}
/**
* returns the directory's default document if any
*/
}, {
key: "getDefaultDocument",
value: function getDefaultDocument(dir) {
var _iterator2 = _createForOfIteratorHelper(this.config.defaultDocuments),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var file = _step2.value;
if (fs__namespace.existsSync(path__namespace.join(dir, file))) {
return file;
}
}
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
return null;
}
/**
* validates the request method and returns the public file or directory path that
* matches the request url
*/
}, {
key: "validateRequest",
value: function validateRequest(url, method) {
if (!['head', 'get', 'options'].includes(method.toLowerCase())) {
return null;
}
url = url.replace(/[#?].*/, '').replace(/\.\./g, '');
var _iterator3 = _createForOfIteratorHelper(this.config.publicPaths),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var publicPath = _step3.value;
var testPath = path__namespace.resolve(this.rootDir, publicPath, url);
if (fs__namespace.existsSync(testPath)) {
var stat = fs__namespace.statSync(testPath);
if (stat.isFile()) {
return testPath;
} else if (stat.isDirectory()) {
//check if there is a default document in the folder
var defaultDocument = this.getDefaultDocument(testPath);
if (defaultDocument) {
return path__namespace.join(testPath, defaultDocument);
}
}
}
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
return null;
}
/**
* serves a static file response back to the client
*/
}, {
key: "serve",
value: function serve(requestPath, requestMethod, requestHeaders, response) {
requestMethod = requestMethod.toLowerCase();
var filePath = this.validateRequest(utils.stripSlashes(requestPath), requestMethod);
if (filePath) {
return this.process(filePath, requestMethod, requestHeaders, response);
}
return Promise.resolve(false);
}
/**
* serves server http error files. such as 504, 404, etc
*/
}, {
key: "serveHttpErrorFile",
value: function serveHttpErrorFile(errorStatusCode, response) {
var httpErrors = this.config.httpErrors;
var filePath = '';
var errorStatusCodeStr = errorStatusCode.toString();
if (httpErrors[errorStatusCodeStr]) {
filePath = path.resolve(this.rootDir, httpErrors.baseDir, httpErrors[errorStatusCode]);
} else {
filePath = path.resolve(__dirname, "../httpErrors/".concat(errorStatusCode, ".html"));
}
if (!fs__namespace.existsSync(filePath) || fs__namespace.statSync(filePath).isDirectory()) {
return response.status(errorStatusCode).end();
} else {
return this.streamFile(filePath, response, errorStatusCode, this.getFileDefaultHeaders(filePath).headers);
}
}
/**
* serves file intended for download to the client
*/
}, {
key: "serveDownload",
value: function serveDownload(filePath, response, filename) {
var found = true;
var absPath = path.resolve(this.rootDir, filePath);
if (!fs__namespace.existsSync(absPath) || fs__namespace.statSync(absPath).isDirectory()) {
found = false;
var _iterator4 = _createForOfIteratorHelper(this.config.publicPaths),
_step4;
try {
for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
var current = _step4.value;
absPath = path.resolve(this.rootDir, current, filePath);
if (fs__namespace.existsSync(absPath) && fs__namespace.statSync(absPath).isFile()) {
found = true;
break;
}
}
} catch (err) {
_iterator4.e(err);
} finally {
_iterator4.f();
}
}
if (!found) {
return Promise.reject(new Error("Could not locate download file at ".concat(filePath)));
} else {
filename = utils.isString(filename) ? filename : path__namespace.parse(absPath).base;
return this.streamFile(absPath, response, 200, {
'Content-Disposition': "attachment; filename=\"".concat(filename, "\"")
});
}
}
}]);
return FileServer;
}();
exports.FileServer = FileServer;
//# sourceMappingURL=FileServer.js.map