/* jslint node: true */ 'use strict'; // ENiGMA½ const Log = require('../../logger.js').log; const ServerModule = require('../../server_module.js').ServerModule; const Config = require('../../config.js').get; const { Errors } = require('../../enig_error.js'); // deps const http = require('http'); const https = require('https'); const _ = require('lodash'); const fs = require('graceful-fs'); const paths = require('path'); const mimeTypes = require('mime-types'); const forEachSeries = require('async/forEachSeries'); const findSeries = require('async/findSeries'); const { loadModulesForCategory, moduleCategories } = require('../../module_util'); const ModuleInfo = (exports.moduleInfo = { name: 'Web', desc: 'Web Server', author: 'NuSkooler', packageName: 'codes.l33t.enigma.web.server', }); exports.WellKnownLocations = { Rfc5785: '/.well-known', Internal: '/_enig', }; class Route { constructor(route) { Object.assign(this, route); if (this.method) { this.method = this.method.toUpperCase(); } try { this.pathRegExp = new RegExp(this.path); } catch (e) { Log.debug({ route: route }, 'Invalid regular expression for route path'); } } isValid() { return ( (this.pathRegExp instanceof RegExp && -1 !== [ 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', ].indexOf(this.method)) || !_.isFunction(this.handler) ); } matchesRequest(req) { return req.method === this.method && this.pathRegExp.test(req.url); } getRouteKey() { return `${this.method}:${this.path}`; } } exports.getModule = class WebServerModule extends ServerModule { constructor() { super(); const config = Config(); this.enableHttp = config.contentServers.web.http.enabled || false; this.enableHttps = config.contentServers.web.https.enabled || false; this.routes = {}; } getDomain() { const config = Config(); const overridePrefix = _.get(config.contentServers.web.overrideUrlPrefix); if (_.isString(overridePrefix)) { const url = new URL(overridePrefix); return url.hostname; } return config.contentServers.web.domain; } buildUrl(pathAndQuery) { // // Create a URL such as // https://l33t.codes:44512/ + |pathAndQuery| // // Prefer HTTPS over HTTP. Be explicit about the port // only if non-standard. Allow users to override full prefix in config. // const config = Config(); if (_.isString(config.contentServers.web.overrideUrlPrefix)) { return `${config.contentServers.web.overrideUrlPrefix}${pathAndQuery}`; } let schema; let port; if (config.contentServers.web.https.enabled) { schema = 'https://'; port = 443 === config.contentServers.web.https.port ? '' : `:${config.contentServers.web.https.port}`; } else { schema = 'http://'; port = 80 === config.contentServers.web.http.port ? '' : `:${config.contentServers.web.http.port}`; } return `${schema}${config.contentServers.web.domain}${port}${pathAndQuery}`; } isEnabled() { return this.enableHttp || this.enableHttps; } createServer(cb) { if (this.enableHttp) { this.httpServer = http.createServer((req, resp) => this.routeRequest(req, resp) ); } const config = Config(); if (this.enableHttps) { const options = { cert: fs.readFileSync(config.contentServers.web.https.certPem), key: fs.readFileSync(config.contentServers.web.https.keyPem), }; // additional options Object.assign(options, config.contentServers.web.https.options || {}); this.httpsServer = https.createServer(options, (req, resp) => this.routeRequest(req, resp) ); } return cb(null); } beforeListen(cb) { if (!this.isEnabled()) { return cb(null); } loadModulesForCategory( moduleCategories.WebHandlers, (module, nextModule) => { const moduleInst = new module.getModule(); try { moduleInst.init(err => { return nextModule(err); }); } catch (e) { logger.log.error(e, 'Exception caught web handler!'); return nextModule(e); } }, err => { return cb(err); } ); } listen(cb) { const config = Config(); forEachSeries( ['http', 'https'], (service, nextService) => { const name = `${service}Server`; if (this[name]) { const port = parseInt(config.contentServers.web[service].port); if (isNaN(port)) { Log.warn( { port: config.contentServers.web[service].port, server: ModuleInfo.name, }, `Invalid port (${service})` ); return nextService( Errors.Invalid( `Invalid port: ${config.contentServers.web[service].port}` ) ); } this[name].listen( port, config.contentServers.web[service].address, err => { return nextService(err); } ); } else { return nextService(null); } }, err => { return cb(err); } ); } addRoute(route) { route = new Route(route); if (!route.isValid()) { Log.warn( { route: route }, 'Cannot add route: missing or invalid required members' ); return false; } const routeKey = route.getRouteKey(); if (routeKey in this.routes) { Log.warn( { route: route, routeKey: routeKey }, 'Cannot add route: duplicate method/path combination exists' ); return false; } this.routes[routeKey] = route; return true; } routeRequest(req, resp) { let route = _.find(this.routes, r => r.matchesRequest(req)); if (route) { return route.handler(req, resp); } else { this.tryStaticRoute(req, resp, wasHandled => { if (!wasHandled) { this.tryRouteIndex(req, resp, wasHandled => { if (!wasHandled) { return this.fileNotFound(resp); } }); } }); } } respondWithError(resp, code, bodyText, title) { const customErrorPage = paths.join( Config().contentServers.web.staticRoot, `${code}.html` ); fs.readFile(customErrorPage, 'utf8', (err, data) => { resp.writeHead(code, { 'Content-Type': 'text/html' }); if (err) { return resp.end(` ${title}

${bodyText}

`); } return resp.end(data); }); } accessDenied(resp) { return this.respondWithError(resp, 401, 'Access denied.', 'Access Denied'); } fileNotFound(resp) { return this.respondWithError(resp, 404, 'File not found.', 'File Not Found'); } tryRouteIndex(req, resp, cb) { const tryFiles = Config().contentServers.web.tryFiles || [ 'index.html', 'index.htm', ]; findSeries( tryFiles, (tryFile, nextTryFile) => { const fileName = paths.join( req.url.substr(req.url.lastIndexOf('/', 1)), tryFile ); const filePath = this.resolveStaticPath(fileName); fs.stat(filePath, (err, stats) => { if (err || !stats.isFile()) { return nextTryFile(null, false); } const headers = { 'Content-Type': mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'), 'Content-Length': stats.size, }; const readStream = fs.createReadStream(filePath); resp.writeHead(200, headers); readStream.pipe(resp); return nextTryFile(null, true); }); }, (_, wasHandled) => { return cb(wasHandled); } ); } tryStaticRoute(req, resp, cb) { const fileName = req.url.substr(req.url.lastIndexOf('/', 1)); const filePath = this.resolveStaticPath(fileName); if (!filePath) { return cb(false); } fs.stat(filePath, (err, stats) => { if (err || !stats.isFile()) { return cb(false); } const headers = { 'Content-Type': mimeTypes.contentType(paths.basename(filePath)) || mimeTypes.contentType('.bin'), 'Content-Length': stats.size, }; const readStream = fs.createReadStream(filePath); resp.writeHead(200, headers); readStream.pipe(resp); return cb(true); }); } resolveStaticPath(requestPath) { const staticRoot = _.get(Config(), 'contentServers.web.staticRoot'); const path = paths.resolve(staticRoot, `.${requestPath}`); if (path.startsWith(staticRoot)) { return path; } } routeTemplateFilePage(templatePath, preprocessCallback, resp) { const self = this; fs.readFile(templatePath, 'utf8', (err, templateData) => { if (err) { return self.fileNotFound(resp); } preprocessCallback(templateData, (err, finalPage, contentType) => { if (err || !finalPage) { return self.respondWithError( resp, 500, 'Internal Server Error.', 'Internal Server Error' ); } const headers = { 'Content-Type': contentType || mimeTypes.contentType('.html'), 'Content-Length': finalPage.length, }; resp.writeHead(200, headers); return resp.end(finalPage); }); }); } };