// Copyright 2019-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (web@campbellcrowley.com) const fs = require('fs'); const path = require('path'); const http = require('http'); const crypto = require('crypto'); const SubModule = require('../subModule.js'); const forbidden = [ path.resolve(__dirname + '/../../auth.js'), path.resolve(__dirname + '/../../gApiCredentials.json'), path.resolve(__dirname + '/../../save/patreonCreatorTokens.json'), ]; const ccMaxAge = 30 * 7 * 24 * 60 * 60; // 30 days /** * @description Serves files from web requests. * @class * @augments SubModule */ class FileServer extends SubModule { /** * @description Serves files from web requests. */ constructor() { super(); /** @inheritdoc **/ this.myName = 'FileServer'; } /** @inheritdoc */ initialize() { this._app = http.createServer((...args) => this._handler(...args)); this._app.on('error', (err) => { if (err.code === 'EADDRINUSE') { this.debug('Port already in use. Shutting down.'); return; } this.error('A webserver error occurred!'); console.error(err); }); const port = this.common.isRelease ? 8022 : 8023; this._app.listen(port, '127.0.0.1'); this.debug('Starting file server on port: ' + port); } /** @inheritdoc */ shutdown() { if (this._app) this._app.close(); } /** * @description Handles all http requests. * * @private * @param {http.IncomingMessage} req The client's request. * @param {http.ServerResponse} res Our response to the client. */ _handler(req, res) { const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || 'ERROR'; const url = req.url.replace(/^\/(www|kamino).spikeybot.com(?:\/dev)?/, ''); const rootIntent = (url.startsWith('/avatars/') && './save/users') || (url.startsWith('/hg/events/') && './save') || null; if (rootIntent && req.method === 'GET') { const file = path.resolve(`${rootIntent}${url}`); if (!this._isPathAcceptable(file)) { this.common.logDebug(`Unacceptable path ${url}`, ip); res.writeHead(403); res.end(); return; } fs.stat(file, (err, stats) => { if (err) { if (err.code === 'ENOENT') { this.common.logDebug(`File not found ${file}`, ip); res.writeHead(404); res.end(); return; } res.writeHead(500); res.end('500 Internal Server Error (FS)'); this.common.error(`Failed to stat file: ${file}`, ip); console.log(err); return; } const ETag = crypto.createHash('sha1') .update(`ETAG-${file}${stats.mtime.getTime()}`) .digest('hex'); res.setHeader('ETag', ETag); if (req.headers['etag'] === ETag) { res.writeHead( 304, {'Cache-Control': `max-age=${ccMaxAge}`, 'ETag': ETag}); res.end(); return; } const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpg', '.gif': 'image/gif', '.json': 'application/json', }; const ext = path.extname(file); const mime = mimeTypes[ext]; if (!mime) { res.writeHead(404); res.end(); this.common.logDebug(`404 (Bad MIME) ${req.method} ${url}`, ip); } else { res.writeHead(200, { 'Content-Type': mime, 'Content-Length': stats.size, }); fs.createReadStream(file).pipe(res); } }); } else { res.writeHead(404); res.end(); this.debug(`404 ${req.method} ${url}`, ip); } } /** * @description Check if given path to a file is allowed to be served to a * client request. * @private * @param {string} file The filename to check. * @returns {boolean} If the filename is allowed. */ _isPathAcceptable(file) { file = path.resolve(file); const proj = path.resolve(__dirname + '/../../'); if (forbidden.includes(proj)) return false; return file.startsWith(proj); } } module.exports = new FileServer();