UNPKG

alchemymvc

Version:
2,289 lines (1,840 loc) 62.5 kB
const FILECACHE = alchemy.getCache('served_files'), RX_TEXT = /svg|xml|javascript|text/i; var libstream = alchemy.use('stream'), libpath = alchemy.use('path'), libua = alchemy.use('useragent'), zlib = alchemy.use('zlib'), BODY = Symbol('body'), TESTED_ROUTES = Symbol('tested_routes'), magic, fs = alchemy.use('fs'), prefixes = alchemy.shared('Routing.prefixes'); /** * The Conduit Class * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.2.0 * * @param {IncomingMessage} req * @param {ServerResponse} res * @param {Router} router */ var Conduit = Function.inherits('Alchemy.Base', 'Alchemy.Conduit', function Conduit(req, res, router) { // Store the starting time this.start = new Date(); // Create a reference to ourselves this.conduit = this; // Debug messages for this request this.debuglog = []; this._debugObject = this.debug({label: 'Initialize Conduit'}); this._debugConduitInitialize = this._debugObject; // Allow use of the log in the views if (alchemy.settings.debugging.debug) { this.internal('debuglog', {_placeholder_: 'debuglog'}); } // Cookies to send to the client this.new_cookies = {}; this.new_cookie_header = []; // The headers to send this.response_headers = {}; // Where the body will go this.body = {}; // Where the files will go this.files = {}; this.initValues(); this.setReqRes(req, res); }); /** * Deprecated property names * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.0.0 * @version 1.1.0 */ Conduit.setDeprecatedProperty('originalPath', 'original_path'); Conduit.setDeprecatedProperty('newCookies', 'new_cookies'); Conduit.setDeprecatedProperty('newCookieHeader', 'new_cookie_header'); Conduit.setDeprecatedProperty('viewRender', 'renderer'); Conduit.setDeprecatedProperty('view_render', 'renderer'); Conduit.setDeprecatedProperty('sceneId', 'scene_id'); /** * Return the cookies * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.prepareProperty(function cookies() { return String.decodeCookies(this.headers.cookie); }); /** * Return the parsed useragent string * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.5.0 */ Conduit.prepareProperty(function useragent() { return libua.lookup(this.headers['user-agent']); }); /** * Create a Hawkejs Renderer * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.5 */ Conduit.prepareProperty(function renderer() { let result; if (this.parent && this.parent != this && this.parent.renderer) { result = this.parent.renderer.createSubRenderer(); } else { result = alchemy.hawkejs.createRenderer(); } return result; }); /** * Get the SessionScene instance * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.10 * @version 1.3.10 */ Conduit.setProperty(function scene() { return this.getSession().getScene(this.scene_id); }); /** * The session cookie name to use * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.18 * @version 1.3.18 */ Conduit.setProperty(function session_cookie_name() { return alchemy.settings.sessions.cookie_name || 'alchemy_sid'; }); /** * Enforce the scene_id * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.3.18 */ Conduit.enforceProperty(function scene_id(new_value, old_value) { if (!new_value) { new_value = this.headers['x-scene-id'] || this.expose('scene_id'); // If there also was no old value, create a new scene if (!new_value && old_value == null) { // Generate the scene_id new_value = Crypto.randomHex(8) || Crypto.pseudoHex(8); // Tell the session this scene can be expected this.getSession().expectScene(new_value, this); let path = this.request?.url; // Set the sceneid cookie this.cookie('scene_start_' + ~~(Math.random()*1000), { // The time this scene has started start: Date.now(), // The id of the scene id: new_value }, { // Cookie should only be visible on this path path: path, // Cookie should not live for more than 15 seconds maxAge: 1000 * 15, // It should be restricted to the same domain domain: false, }); } } this.expose('scene_id', new_value); return new_value; }); /** * Enforce the active_prefix * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.5 */ Conduit.enforceProperty(function active_prefix(new_value, old_value) { if (!new_value) { this.renderer.language = null; return null; } if (new_value == old_value) { return new_value; } // Set the active prefix this.internal('active_prefix', new_value); this.expose('active_prefix', new_value); if (this.locales[0] != new_value) { this.locales.unshift(new_value); } // Set the translate options for use in hawkejs this.internal('locales', this.locales); this.expose('locales', this.locales); let config = Prefix.get(new_value); if (config) { this.renderer.language = config.locale; } return new_value; }); /** * Get a session object by id * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.setStatic(function getSessionById(id) { return alchemy.sessions.get(id); }); /** * See if this is a secure connection * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.4.2 * @version 1.0.2 */ Conduit.setProperty(function is_secure() { var protocol; if (alchemy.settings.network.assume_https) { return true; } if (this.headers && this.headers['x-forwarded-proto'] == 'https') { return true; } if (this.url && this.url.protocol == 'https:') { return true; } if (this.protocol && this.protocol.startsWith('https')) { return true; } if (this.encrypted == true) { return true; } return false; }); /** * Set the request body * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.3.18 * * @param {Object|string} */ Conduit.setMethod(function setRequestBody(body) { if (typeof body == 'string') { this.body = body; return; } if (!body) { return; } Object.assign(this.body, body); }); /** * Has the given route been tested yet? * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.5 * @version 1.2.5 * * @param {Route} */ Conduit.setMethod(function hasRouteBeenTested(route) { if (!route || !this[TESTED_ROUTES]) { return false; } return this[TESTED_ROUTES].has(route); }); /** * Mark this route as having been tested * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.5 * @version 1.2.5 * * @param {Route} */ Conduit.setMethod(function markRouteAsTested(route) { if (!this[TESTED_ROUTES]) { this[TESTED_ROUTES] = new Set(); } this[TESTED_ROUTES].add(route); }); /** * Rewrite a certain URL parameter * (Causing some kind of redirect) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.5 * @version 1.2.5 * * @param {string} route_param * @param {*} new_value */ Conduit.setMethod(function rewriteRequestRouteParam(route_param, new_value) { if (!this.rewritten_request_route_param) { this.rewritten_request_route_param = {}; } this.rewritten_request_route_param[route_param] = new_value; }); /** * Set the request files * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {Object} */ Conduit.setMethod(function setRequestFiles(files) { if (!files) { return; } _setRequestFiles(this, files, this.files); }); /** * Set the request files * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.0 * @version 1.3.0 * * @param {Conduit} conduit * @param {Array} files * @param {Object} target */ function _setRequestFiles(conduit, files, target) { let context, upload, entry, key; for (key in files) { entry = files[key]; if (Array.isArray(entry)) { context = target[key]; if (!context) { context = target[key] = {}; } _setRequestFiles(conduit, entry, context); } else { target[key] = Classes.Alchemy.Inode.File.fromUntrusted(entry); } } } /** * Don't convert a conduit to any special json data * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.setMethod(function toJSON() { return null; }); /** * Set the request & response objects * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.0 * @version 1.2.0 */ Conduit.setMethod(function setReqRes(req, res) { if (req != null) { // Make conduit available in req req.conduit = this; // Basic HTTP objects this.request = req; // The HTTP request headers this.headers = req.headers; // Parse the original URL without host this.original_url = new RURL(req.url); // Is this an AJAX request? this.ajax = null; } if (res != null) { this.response = res; } }); /** * Init values * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.3.3 * @version 1.3.15 */ Conduit.setMethod(function initValues() { // Use passed-along router, or default router instance this.router = this.router || Router; // The path without any prefix, including section mounts this.path = null; // The path without prefix or section mount this.sectionPath = null; // The accepted languages this.languages = null; // URL paths can be prefixed with certain locales, // these locales should then get preference over the user's browser locale this.prefix = null; // All the locales the user's browser accepts this.locales = null; // The matching Route instance this.route = null; // The named parameters inside the path this.params = null; // The original string parameters this.route_string_parameters = null; // The section vhost domain this.sectionDomain = null; // The section of the used route this.section = null; // The parsed path (including querystring) this.url = null // The current active theme this.theme = null; // Make sure the tested routes are reset this[TESTED_ROUTES] = null; }); /** * Get the time since the conduit was made * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.0 */ Conduit.setMethod(function time() { return Date.now() - this.start; }); /** * Parse the request, get information from the url * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.18 * * @param {IncomingMessage} req * @param {ServerResponse} res */ Conduit.setMethod(async function parseRequest() { if (this.method == null && this.request && this.request.method) { this.method = this.request.method.toLowerCase(); } this.parseUrl(); this.parseShortcuts(); this.parseLanguages(); this.parsePrefix(); this.parseSection(); // Try getting the route await this.parseRoute(); if (this.halt_request) { return false; } // Is this encrypted? if (this.encrypted == null) { this.encrypted = this.request.connection.encrypted; } if (this.rewritten_request_route_param) { let params = Object.assign({}, this.route_string_parameters, this.rewritten_request_route_param); let new_url = this.route.generateUrl(params, this); this.overrideResponseUrl(new_url); } this.parseUrl(); }); /** * Parse the complete request URL if it hasn't been done yet * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.18 * @version 1.3.19 */ Conduit.setMethod(function parseUrl() { // If the url has already been parsed, return early if (this.url) { return false; } let protocol; if (alchemy.settings.network.assume_https) { protocol = 'https://'; } else if (this.headers['x-forwarded-proto']) { protocol = this.headers['x-forwarded-proto']; } else if (this.headers[':scheme']) { protocol = this.headers[':scheme']; } else if (this.protocol) { protocol = this.protocol; } else if (this.encrypted) { protocol = 'https://'; } else { protocol = 'http://'; } // Create a new RURL instance this.url = new RURL(); // Set the protocol this.url.protocol = protocol; // Get the host/domain/authority of the request const host = this.headers[':authority'] || this.headers.host; // Set the host this.url.hostname = host; // If no URL was set, use the first requests URL (without the path) if (!alchemy.settings.network.main_url && host) { alchemy.setUrl(this.url); } // Set the entire original path this.url.path = this.original_path; return true; }); /** * Parse the headers for shortcuts * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.17 */ Conduit.setMethod(function parseShortcuts() { var headers = this.headers; // A request can just tell us what route to use if (headers['x-alchemy-route-name']) { this.route = this.router.getRouteByName(headers['x-alchemy-route-name']); } // And which prefix (this is a forced prefix) if (headers['x-alchemy-prefix'] && prefixes[headers['x-alchemy-prefix']]) { this.prefix = headers['x-alchemy-prefix']; } // Section domains can only be requested through headers if (headers['x-alchemy-section-domain']) { this.sectionDomain = headers['x-alchemy-section-domain']; } // Only get ajax on the first parse if (this.ajax == null) { this.ajax = headers['x-requested-with'] === 'XMLHttpRequest'; } // Is this request coming from a webview? if (this.in_webview == null) { this.in_webview = headers['x-protoblast-webview'] === 'true'; } }); /** * Sort the parsed accept-language header array * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.0.1 * @version 0.0.1 * * @param {Object} a * @param {Object} b */ function qualityCmp(a, b) { if (a.quality === b.quality) { return 0; } else if (a.quality < b.quality) { return 1; } else { return -1; } } /** * Parses the HTTP accept-language header * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.0.1 * @version 0.2.0 */ Conduit.setMethod(function parseLanguages() { var rawLangs, rawLang, locales, parts, langs, qval, temp, i, q; langs = []; locales = []; if (this.headers['accept-language']) { rawLangs = this.headers['accept-language'].split(','); for (i = 0; i < rawLangs.length; i++) { rawLang = rawLangs[i]; parts = rawLang.split(';'); qval = null; q = 1; if (parts.length > 1 && parts[1].indexOf('q=') === 0) { qval = parseFloat(parts[1].split('=')[1]); if (isNaN(qval) === false) { q = qval; } } // Get the lang-loc code temp = parts[0].trim().toLowerCase().split('-'); langs.push({lang: temp[0], loc: temp[1], quality: q}); } langs.sort(qualityCmp); }; temp = {}; for (i = 0; i < langs.length; i++) { if (!temp[langs[i].lang]) { locales.push(langs[i].lang); temp[langs[i].lang] = true; } } this.languages = langs; this.locales = locales; }); /** * Parses accept-encoding strings * * @author jshttp * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ function parseEncoding(s, i) { var match = s.match(/^\s*(\S+?)\s*(?:;(.*))?$/); if (!match) return null; var encoding = match[1]; var q = 1; if (match[2]) { var params = match[2].split(';'); for (var i = 0; i < params.length; i ++) { var p = params[i].trim().split('='); if (p[0] === 'q') { q = parseFloat(p[1]); break; } } } return { encoding: encoding, q: q, i: i }; } /** * Parses accept-encoding strings * * @author jshttp * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ function specify(encoding, spec, index) { var s = 0; if(spec.encoding.toLowerCase() === encoding.toLowerCase()){ s |= 1; } else if (spec.encoding !== '*' ) { return null } return { i: index, o: spec.i, q: spec.q, s: s } }; /** * Parses the HTTP accept-encoding header * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.setMethod(function parseAcceptEncoding() { var hasIdentity, minQuality, encoding, accepts, i, j; // Make sure this only runs once if (this.accepted_encodings != null) { return; } if (!this.headers['accept-encoding']) { this.accepted_encodings = false; return; } accepts = this.headers['accept-encoding'].split(','); minQuality = 1; for (i = 0, j = 0; i < accepts.length; i++) { encoding = parseEncoding(accepts[i].trim(), i); if (encoding) { accepts[j++] = encoding; hasIdentity = hasIdentity || specify('identity', encoding); minQuality = Math.min(minQuality, encoding.q || 1); } } if (!hasIdentity) { /* * If identity doesn't explicitly appear in the accept-encoding header, * it's added to the list of acceptable encoding with the lowest q */ accepts[j++] = { encoding: 'identity', q: minQuality, i: i }; } // trim accepts accepts.length = j; this.accepted_encodings = accepts; }); /** * See if the wanted encoding is accepted by the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.setMethod(function accepts(encoding) { var i; // Parse the encodings on the fly this.parseAcceptEncoding(); if (!this.accepted_encodings) { return false; } for (i = 0; i < this.accepted_encodings.length; i++) { if (this.accepted_encodings[i].encoding == encoding) { return true; } } return false; }); /** * Create a loopback conduit * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.3 * * @param {Object} args * @param {Function} callback * * @return {Alchemy.LoopbackConduit} */ Conduit.setMethod(function loopback(args, callback) { return Classes.Alchemy.Conduit.Loopback.create(this, args, callback); }); /** * Parse the request, get information from the url * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.0 */ Conduit.setMethod(function parsePrefix() { let path = this.original_path; if (!path) { return; } let active_prefix, prefix, begin; // Look for the prefix at the beginning of the url path if (!this.prefix) { for (prefix in prefixes) { begin = '/' + prefix + '/'; if (path.indexOf(begin) === 0) { this.prefix = prefix; break; } } } // Handle urls with ONLY the prefix and no ending slash if (!this.prefix) { for (prefix in prefixes) { begin = '/' + prefix; if (this.original_pathname == begin) { this.prefix = prefix; break; } } if (this.prefix && path.endsWith('/' + this.prefix)) { this.path = '/'; } else if (this.prefix && path.startsWith('/' + this.prefix + '?')) { this.path = '/?' + path.after('?'); } else { this.path = path; } } else if (this.prefix && path.indexOf('/' + this.prefix + '/') === 0) { // Remove the prefix from the path if one is given this.path = path.slice(this.prefix.length+1); } else { this.path = path; } // Add this prefix to the top of the locales if (this.prefix) { active_prefix = this.prefix; this.locales.unshift(this.prefix); // Remember this prefix in the session this.session('last_forced_prefix', this.prefix); // Let the client know this prefix should be used this.expose('forced_prefix', this.prefix); } else { let last_forced_prefix = this.session('last_forced_prefix'); if (last_forced_prefix) { active_prefix = last_forced_prefix; } else if (this.active_prefix) { // There already is an active prefix, so just keep on using that // (Is the case in redirects) return; } else { // If no prefix has been found yet, look for the default prefix // This will override the browser locale if (this.headers['x-alchemy-default-prefix']) { if (prefixes[this.headers['x-alchemy-default-prefix']]) { active_prefix = this.headers['x-alchemy-default-prefix']; if (this.locales[0] != active_prefix) { this.locales.unshift(active_prefix); } } } if (!active_prefix) { active_prefix = this.locales[0]; } } } this.active_prefix = active_prefix; }); /** * Get the section * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.1 */ Conduit.setMethod(function parseSection() { // Get the section this path is using this.section = this.router.getPathSection(this.path); if (!this.section) { log.warn('No section found for path "' + this.path + '"'); } // If the section has a parent it's not the root if (this.section && this.section.parent) { this.sectionPath = this.path.slice(this.section.mount.length) || '/'; } else { this.sectionPath = this.path; } }); /** * Get a route by its name * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 * * @param {string|Object} name The name of the route */ Conduit.setMethod(function getRouteByName(name) { // See if the name is an object, which means it's for sockets if (name && typeof name == 'object') { this.route = name; } else { this.route = this.router.getRouteByName(name); } return this.route; }); /** * Get the Route instance & named parameters * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.0 * * @param {Route} after_route Only check routes after this one * * @return {boolean} Continue processing this request or not? */ Conduit.setMethod(async function parseRoute(after_route) { var temp; this.section = this.router.getPathSection(this.path); // Remove the current found route if (after_route) { this.route_rematch = true; this.route = null; } // If the route hasn't been found in the header shortcuts yet, look for it if (!this.route) { temp = this.router.getRouteBySectionPath(this, this.method, this.section, this.sectionPath, this.prefix, after_route); if (temp && temp.then) { temp = await temp; } if (temp) { this.route = temp.route; this.setRouteParameters(temp.parameters); this.route_string_parameters = temp.original_parameters; this.path_definition = temp.definition; } else { // Is this a HEAD request? Then we need to check if a GET exists if (this.method == 'head') { let get_route = this.router.getRouteBySectionPath(this, 'get', this.section, this.sectionPath, this.prefix, after_route); if (get_route && get_route.then) { get_route = await get_route; } // A GET route was found, so we just need to end this request if (get_route) { this.end(); this.halt_request = true; return; } } else { // See if the path matches another method temp = await this.router.getRouteBySectionPath(this, ['get', 'post', 'put'], this.section, this.sectionPath, this.prefix, after_route); if (temp) { this.route_mismatch = temp.route; temp = null; } } this.route_not_found = true; } } else { temp = this.route.match(this, this.method, this.sectionPath); if (temp && temp.then) { temp = await temp; } if (temp) { this.setRouteParameters(temp.parameters); this.route_string_parameters = temp.original_parameters || {}; this.path_definition = temp.definition; } else { this.setRouteParameters(); } } }); /** * Run the middleware * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.5 */ Conduit.setMethod(async function callMiddleware() { if (!this.section) { return this.callHandler(); } let that = this, middlewares = await this.section.getMiddleware(this, this.section, this.path, this.prefix), debugObject = this._debugObject, middleDebug = this.debug({label: 'middleware', data: {title: 'Doing middleware'}}), routeDebug, theme; if (middleDebug) { this._debugObject = middleDebug; } middlewares = new Iterator(middlewares); Function.while(function test() { return middlewares.hasNext(); }, function middlewareTask(next) { var route = middlewares.next().value, middlePath, req; // Skip middleware that does not listen to the request method if (route.methods.indexOf(that.method) === -1) { return next(); } // Augment the request object req = Object.create(that.request); // Get the path without the middleware mount path middlePath = req.conduit.sectionPath.replace(route.paths[''].source, ''); // Strip any query parameters if (middlePath.indexOf('?') > -1) { middlePath = middlePath.before('?'); } if (middlePath[0] !== '/') { middlePath = '/' + middlePath; } // Look for theme settings if (req.conduit.url) { theme = req.conduit.url.query.theme; if (theme) { middlePath = ['/' + theme + middlePath, middlePath]; } } req.middlePath = middlePath; req.original = that.request; if (routeDebug) { routeDebug.stop(); } if (middleDebug) { routeDebug = middleDebug.debug('route', {title: 'Doing "' + route.name + '"'}); that._debugObject = routeDebug; } route.fnc(req, that.response, next); }, function done(err) { if (err) { return that.emit('error', err); } if (routeDebug) { routeDebug.stop(); } // Don't do this for websockets if (that.websocket) { return; } if (middleDebug) { middleDebug.mark('Preparing viewrender'); } // An action will be called, // so we can already prepare the viewrender now. that.prepareViewRender(); if (middleDebug) { middleDebug.mark(false); middleDebug.stop(); } if (that._debugConduitInitialize) { that._debugConduitInitialize.stop(); } // Return the original debug object that._debugObject = debugObject; that.callHandler(); }); }); /** * Create a new Hawkejs' ViewRender instance * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.17 */ Conduit.setMethod(function prepareViewRender() { if (this.renderer.conduit) { return; } // Add a link to this conduit this.renderer.conduit = this; this.renderer.server_var('conduit', this); // Let the ViewRender get some request info this.renderer.prepare(this.request, this); // Pass url parameters to the client this.renderer.internal('urlparams', this.route_string_parameters); this.renderer.internal('url', this.url); if (this.route) { this.renderer.internal('route', this.route.name); } this.renderer.is_for_client_side = this.ajax; }); /** * Call the handler of this route when parsing is finished * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.16 */ Conduit.setMethod(function callHandler() { if (!this.route) { if (this.route_mismatch) { if (alchemy.settings.debugging.debug) { console.log('Route method not allowed:', this); } this.error(405, 'Method Not Allowed', false); } else { if (alchemy.settings.debugging.debug && this.original_path.indexOf('.js.map') == -1) { console.log('Route not found:', this); } this.notFound('Route was not found'); } return; } this.route.callHandler(this); }); /** * Put this request in a queue * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.1 * @version 1.3.1 * * @param {Object} options Options or url */ Conduit.setMethod(function postponeAndQueue(options) { if (!options) { options = {}; } const postponement = this.postponeRequest({ put_in_queue : true, }); return postponement; }); /** * Postpone the response and the request * * This does not stop the current request from processing. * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.1 * @version 1.3.1 * * @param {number|Object} options Options or time to wait * * @return {Alchemy.Conduit.Postponement} */ Conduit.setMethod(function postponeRequest(options) { let postponement = this.postponeResponse(options); this.afterOnce('get-postponed-response', () => { this.callMiddleware(); }); return postponement; }); /** * End the current request with a 202 status * and tell the client to look at another url later. * * This does not stop the current request from processing. * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.3.1 * * @param {number|Object} options Options or time to wait * * @return {Alchemy.Conduit.Postponement} */ Conduit.setMethod(function postponeResponse(options) { if (typeof options == 'number') { options = { expected_duration: options }; } else if (!options) { options = {}; } return this._postpone(options); }); /** * Handle the postponement * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.1 * @version 1.3.1 * * @param {Object} options * * @return {Alchemy.Conduit.Postponement} */ Conduit.setMethod(function _postpone(options) { let session = this.getSession(); let postponement = session.getExistingPostponement(this); if (postponement) { return postponement.showPostponementMessage(this); } let response = this.response; this.postponed_response = response; // Make sure the scene id exists this.createScene(); postponement = session.postpone(this, options); if (options.put_in_queue) { postponement.putInQueue(); } if (options.show_postponement_message !== false) { postponement.showPostponementMessage(); } // Nullify the response this.response = null; // Set the original url this.overrideResponseUrl(this.url); // Return the postponement return postponement; }); /** * Set the response url * * @deprecated Use {@link #setResponseUrl} instead * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.2.5 * @version 1.3.0 * * @param {string|RURL} url */ Conduit.setMethod(function overrideResponseUrl(url) { return this.setResponseUrl(url); }); /** * Set the response url * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.0 * @version 1.3.0 * * @param {string|RURL|boolean} new_url */ Conduit.setMethod(function setResponseUrl(new_url) { if (new_url == null) { return; } if (!new_url) { this.renderer.history = false; return; } else { this.renderer.history = true; } if (typeof new_url != 'string') { new_url = String(new_url); } this.setHeader('x-history-url', new_url); this.expose('redirected_to', new_url); }); /** * Redirect to another url * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.17 * * @param {number} status 3xx redirection codes. 302 (temporary redirect) by default * @param {string|Object} options Options or url */ Conduit.setMethod(function redirect(status, options) { let hard_refresh = false, url; if (typeof status != 'number') { if (typeof options == 'object') { options.url = status; } else { options = status; } status = 302; } if (typeof options == 'object' && options) { if (options.href || options.path) { url = options.href || options.path; } else { if (options.body) { Object.defineProperty(this, 'body', { value : options.body, configurable : true }); } if (options.method) { this.method = options.method; } // When headers are given, the redirect is internal if (options.headers) { this.headers = options.headers; this.oldoriginal_path = this.original_path; if (typeof options.url == 'string') { let temp = options.url; temp = RURL.parse(temp); url = temp.path; } else { url = options.url.path; } if (url == null) { throw new Error('Conduit#redirect can not redirect to null path'); } // Register the new url as the one to use for the history this.overrideResponseUrl(url); this.original_path = url; // Reinitialize the conduit this.initValues(); this.initHttp(); return; } else { url = options.url; } } if (options.hard_refresh) { hard_refresh = options.hard_refresh; } } else if (typeof options == 'string') { url = options; options = null; } else { throw new Error('Conduit#redirect requires a valid url or options object'); } this.status = status; // Make sure the url is an internal one if no hard refresh is requested if (!hard_refresh && alchemy.settings.network.main_url) { let rurl = RURL.parse(url); // If an explicit hostname is set, this might be an external url if (rurl.hostname) { let base_url = RURL.parse(alchemy.settings.network.main_url); if (base_url.hostname != rurl.hostname) { hard_refresh = true; } } } if (options?.popup) { this.setHeader('x-hawkejs-popup', options.popup); if (options.popup_url) { this.setHeader('x-hawkejs-popup-url', options.popup_url); } hard_refresh = true; } if (hard_refresh && this.headers['x-hawkejs-request']) { this.setHeader('x-hawkejs-navigate', url); } else { this.setHeader('Location', url); } this._end(); }); /** * Respond with an error * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.21 * * @param {Nulber} status Response statuscode * @param {Error} message Optional error to send * @param {boolean} print_error Print the error, defaults to true */ Conduit.setMethod(function error(status, message, print_error) { let print_dev = false; if (status instanceof Classes.Alchemy.Error.HTTP) { message = status; status = message.status; } if (typeof status !== 'number') { message = status; status = 500; } if (!message) { message = 'Unknown server error'; } if (typeof message == 'string') { let error = new Classes.Alchemy.Error.HTTP(status, message); error[Symbol.for('extra_skip_levels')] = 1; message = error; } let is_400 = (status >= 400 && status <= 500); if (alchemy.settings.environment == 'dev') { print_dev = true; if (is_400 && this.original_path && this.original_path.indexOf('.js.map') > -1) { print_dev = false; print_error = false; } } if (print_error == null) { if (is_400) { print_error = false; } else { print_error = true; } } if (print_dev) { let subject = 'Error found on ' + this.original_path + ''; log.error(subject, message, this); } else if (print_error) { let subject = 'Error found on ' + this.original_path + ''; if (is_400) { log.error(subject + ':\n' + message); } else if (message instanceof Error) { alchemy.printLog('error', [subject, String(message), message], {err: message, level: -2}); } else { log.error(subject + ':\n' + message); } } // Make sure the client doesn't expect compression this.setHeader('content-encoding', ''); this.status = status; // Only render an expensive "Error" template when the client directly // browses to an HTML page. // Don't render a template for AJAX or asset requests if (this.renderer && (this.ajax || (this.headers.accept && this.headers.accept.indexOf('html') > -1))) { // Hawkejs will have the option to throw the error OR render the error if (this.ajax) { this.end({ error : true, status : status, message : message, error_templates : ['error/' + status, 'error/unknown'], }); } else { this.set('status', status); this.set('message', message); if (alchemy.isTooBusyForRequests()) { this._end(`Error ${status}:\n${message}`); } else { this.render(['error/' + status, 'error/unknown']); } } } else { // Requests for images or scripts just get a non-expensive string response this.setHeader('content-type', 'text/plain'); this._end(status + ':\n' + message + '\n'); } // Emit this as a conduit error alchemy.emit('conduit_error', this, status, message); }); /** * Deny access * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.0 * * @param {number} status * @param {Error} message optional error to send */ Conduit.setMethod(function deny(status, message) { if (typeof status == 'string') { message = status; status = 403; } else if (status instanceof Classes.Alchemy.Error.HTTP) { return this.error(status); } if (message == null) { message = 'Forbidden'; } this.error(status, message); }); /** * The current user is not authorized and needs to log in * (Default implementation, is overriden by the acl plugin) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.0.7 * @version 1.0.7 * * @param {boolean} tried_auth Indicate that this was an auth attempt */ Conduit.setMethod(function notAuthorized(tried_auth) { return this.deny(); }); /** * The current user is authenticated, but not allowed * (Default implementation, is overriden by the acl plugin) * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.0.7 * @version 1.1.0 */ Conduit.setMethod(function forbidden() { let error = new Classes.Alchemy.Error.HTTP(403, 'Forbidden'); error.skipTraceLines(1); return this.deny(error); }); /** * Respond with a not found error status * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.0 * * @param {Error} message optional error to send */ Conduit.setMethod(async function notFound(message) { // Look for other paths if (!this.route_not_found && this.route && !this.route_rematch) { // Try matching against paths after the ones we currently matched await this.parseRoute(this.route); // Call the handler of that route if it has been found if (this.route) { return this.route.callHandler(this); } } if (message == null) { message = 'Not found'; } this.error(404, message, false); }); /** * Respond with a "Not Modified" 304 status * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.4.0 */ Conduit.setMethod(function notModified() { this.status = 304; this._end(); }); /** * Respond with text. Objects get JSON-dry encoded * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.0 * * @param {string|Object} message */ Conduit.setMethod(function end(message) { var that = this, json_type, json_fnc, cache, etag, temp; if (this.websocket) { throw new Error('You can not end a websocket, use the callback instead'); } if (this.method == 'head') { return this._end(); } if (typeof message !== 'string') { if (message && message instanceof Classes.Stream.Stream) { return this._endWithStream(message); } // Use regular JSON if DRY has been disabled in settings if (alchemy.settings.network.use_json_dry_response === false || this.json_dry === false) { json_type = 'json'; json_fnc = JSON.stringify; } else { json_type = 'json-dry'; json_fnc = JSON.dry; // Clone the object message = JSON.clone(message, 'toHawkejs'); } // Only send the mimetype if it hasn't been set yet if (this.setHeader('content-type') == null) { this.setHeader('content-type', "application/" + json_type + ";charset=utf-8"); } message = json_fnc(message) || 'null'; } cache = this.headers['cache-control'] || this.headers['pragma']; // Only generate etags when caching is enabled locally & on the browser if (alchemy.settings.performance.cache !== false && (cache == null || cache != 'no-cache')) { // Calculate the hash as etag etag = alchemy.checksum(message); if (etag != null) { if (this.headers['if-none-match'] == etag) { return this.notModified(); } // Responses through `end` should always be privately cached this.setHeader('cache-control', 'private'); // Send the hash as a response header this.setHeader('etag', etag); } } // No need to replace anything if debugging is disabled or the log is empty if (alchemy.settings.debugging.debug && this.debuglog && this.debuglog.length && message.indexOf('_placeholder_') > -1) { temp = JSON.dry(this.debuglog); message = message.replace(/{\s*"_placeholder_":\s*"debuglog"\s*}/g, temp); message = message.replace(/{\s*\\"_placeholder_\\":\s*\\"debuglog\\"\s*}/g, JSON.stringify(temp).slice(1,-1)); } // Compress the output if the client accepts it, // but only if the file is at least 150 bytes if (alchemy.settings.network.use_compression !== false && message.length > 150 && this.accepts('gzip')) { // Set the decompressed content-length for use in progress bars this.setHeader('x-decompressed-content-length', Buffer.byteLength(message)); // Set the gzip header this.setHeader('content-encoding', 'gzip'); // Make sure proxy servers only cache this under this content-encoding type this.setHeader('vary', 'accept-encoding'); zlib.gzip(message, function gotZipped(err, zipped) { that._end(zipped, 'utf-8'); }); return; } this._end(message, 'utf-8'); }); /** * End with a stream * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.22 * @version 1.3.22 */ Conduit.setMethod(function _endWithStream(stream) { this.ended = new Date(); this.emit('ending'); this.flushHeaders(); stream.pipe(this.response); }); /** * Call the actual end method * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.22 */ Conduit.setMethod(function _end(message, encoding = 'utf-8') { this.ended = new Date(); this.emit('ending'); if (!this.response) { let args = []; if (arguments.length) { args.push(message); args.push(encoding); } this._end_arguments = args; this.emit('after-postponed-end', args); return; } // Set the content-length if it hasn't been set yet if (arguments.length > 0 && !this.response_headers['content-length']) { this.response_headers['content-length'] = Buffer.byteLength(message); } this.flushHeaders(); if (arguments.length === 0) { return this.response.end(); } // End the response return this.response.end(message, encoding); }); /** * Flush the headers * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.22 * @version 1.3.22 */ Conduit.setMethod(function flushHeaders() { if (this._flushed) { return; } this._flushed = true; if (this.status) { this.response.statusCode = this.status; } let value, key; for (key in this.response_headers) { value = this.response_headers[key]; if (value == null) { continue; } this.response.setHeader(key, value); } if (this.new_cookie_header.length) { this.response.setHeader('set-cookie', this.new_cookie_header); } // Write the actual headers this.response.writeHead(this.status); }); /** * Send a response to the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 0.2.0 */ Conduit.setMethod(function send(content) { return this.end(content); }); /** * Create the scene id (if it doesn't exist already) * We do this using cookies, so the HTML response can be cached * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.1.0 */ Conduit.setMethod(function createScene() { return this.scene_id; }); /** * Render a view and send it to the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.17 */ Conduit.setMethod(function render(template_name, options, callback) { let that = this, templates; if (typeof options == 'function') { callback = options; options = {}; } else if (options == null) { options = {}; } // Make sure the viewrender is prepared // (Fallback) this.prepareViewRender(); if (template_name) { templates = [template_name]; } if (templates) { templates.push('error/404'); } // Expose the useragent info to the hawkejs renderer this.internal('useragent', this.useragent); this.createScene(); // Pass along the clientRender property, // can be used to force rendering HTML if (options.clientRender != null) { this.renderer.clientRender = options.clientRender; } if (this.layout) { this.renderer.layout = this.layout; } this.renderer.renderHTML(templates).done(function afterRender(err, html) { var mimetype; if (err != null) { if (callback) { return callback(err); } throw err; } that.registerBindings(); if (typeof html !== 'string') { // Stringify using json-dry html = JSON.dry(html); // Tell the client to expect a json-dry response mimetype = 'application/json-dry'; } else { mimetype = 'text/html'; } // Only send the mimetype if it hasn't been set yet if (that.setHeader('content-type') == null) { that.setHeader('content-type', mimetype + ";charset=utf-8"); } if (callback) { return callback(null, html); } that.end(html); }); }); /** * Convert a buffer into a stream * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.1.0 * @version 1.1.0 * * @param {Buffer} buffer * * @return {Readable} */ function bufferToStream(buffer) { const readable_stream = new libstream.Readable({ read() { this.push(buffer); this.push(null); } }); return readable_stream; } /** * Send a file to the browser * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.0 * * @param {string} path The path on the server to send to the browser * @param {Object} options Options, including headers */ Conduit.setTypedMethod([Types.String, Types.Object.optional()], function serveFile(path, options = {}) { let file = FILECACHE.get(path); if (!file) { file = new Classes.Alchemy.Inode.File(path); } return this.serveFile(file, options); }); /** * Send a file to the browser * * @author Jelle De Loecker <jelle@elevenways.be> * @since 0.2.0 * @version 1.3.13 * * @param {Alchemy.Inode.File} file The file to serve * @param {Object} options Options, including headers */ Conduit.setTypedMethod([Types.Alchemy.Inode.File, Types.Object.optional()], async function serveFile(file, options = {}) { if (file.path && !FILECACHE.has(file.path)) { FILECACHE.set(file.path, file); } let stats = await file.getStats(); if (alchemy.settings.performance.cache && (!options.cache_time && options.cache_time !== false)) { options.cache_time = stats.mtime; } if (options.content_length == null) { options.content_length = stats.size; } if (!options.mimetype) { options.mimetype = await file.getMimetype(); } if (!options.filename) { options.filename = file.name; } return this.serveFile(file.createReadStream(), options); }); /** * Send a buffer to the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.0 * @version 1.3.0 * * @param {Stream} buffer The buffer to send * @param {Object} options Options, including headers */ Conduit.setTypedMethod([Buffer, Types.Object.optional()], function serveFile(buffer, options = {}) { return this.serveFile(bufferToStream(buffer), options); }); /** * Send a stream to the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.0 * @version 1.3.0 * * @param {Stream} stream The stream to send * @param {Object} options Options, including headers */ Conduit.setTypedMethod([Types.Stream, Types.Object.optional()], function serveFile(stream, options = {}) { let is_text = false; if (options.mimetype && RX_TEXT.test(options.mimetype)) { options.mimetype += '; charset=utf-8'; is_text = true; } if (alchemy.settings.performance.cache === false) { options.cache_time = null; } if (options.compress == null) { options.compress = is_text; } if (options.compress && (alchemy.settings.network.use_compression === false || !this.accepts('gzip'))) { options.compress = false; } if (options.cache_time && !alchemy.settings.performance.cache) { options.cache_time = false; } if (!options.onError) { options.onError = err => this.notFound(err); } return this._sendStream(stream, options); }); /** * Send a stream to the client * * @author Jelle De Loecker <jelle@elevenways.be> * @since 1.3.0 * @version 1.3.14 * * @param {Stream} stream The stream to send * @param {Object} options Options, including headers */ Conduit.setMethod(function _sendStream(stream, options) { if (options.cache_time) { let modified_since = this.headers['if-modified-since']; if (modified_since != null) { let since = new Date(modified_since); // If the file's modifytime is smaller or equal to the since time, // don't serve the contents! if (options.cache_time <= since) { return this.notModified(); } } // Allow the browser to cache this for 60 minutes, // after which it has to revalidate the content // by seeing if it has been modified this.setHeader('cache-control', 'public, max-age=3600, must-revalidate'); this.setHeader('last-modified', options.cache_time.toGMTString()); } else { this.setHeader('cache-control', 'no-cache'); } let disposition, key; if (options.mimetype) { this.setHeader('content-type', options.mimetype); } // Setting the disposition makes the browser download the file // This is on by default, but can be disabled if (options.disposition == 'inline') { disposition = 'inline'; if (options.filename) { disposition += '; filename=' + JSON.stringify(options.filename) } this.setHeader('content-disposition', disposition); } else if (options.disposition !== false) { if (options.filename) { disposition = 'attachment; filename=' + JSON.stringify(options.filename); } else { disposition = 'attachment'; } this.setHeader('content-disposition', disposition);