'use strict'; //////////////////////////////////////////////////////////////////////////////// /// @brief Foxx routing /// /// @file /// /// DISCLAIMER /// /// Copyright 2013-2015 triagens GmbH, Cologne, Germany /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// /// Copyright holder is triAGENS GmbH, Cologne, Germany /// /// @author Dr. Frank Celler /// @author Alan Plum /// @author Copyright 2013-2015, triAGENS GmbH, Cologne, Germany //////////////////////////////////////////////////////////////////////////////// var arangodb = require("@arangodb"); var ArangoError = arangodb.ArangoError; var errors = arangodb.errors; var preprocess = require("@arangodb/foxx/preprocessor").preprocess; var _ = require("lodash"); var fs = require("fs"); var is = require("@arangodb/is"); var console = require("console"); var actions = require("@arangodb/actions"); //////////////////////////////////////////////////////////////////////////////// /// @brief excludes certain files //////////////////////////////////////////////////////////////////////////////// var excludeFile = function (name) { var parts = name.split('/'); if (parts.length > 0) { var last = parts[parts.length - 1]; // exclude all files starting with . if (last[0] === '.') { return true; } } return false; }; //////////////////////////////////////////////////////////////////////////////// /// @brief builds one asset of an app //////////////////////////////////////////////////////////////////////////////// var buildAssetContent = function(app, assets, basePath) { var i, j, m; var reSub = /(.*)\/\*\*$/; var reAll = /(.*)\/\*$/; var files = []; for (j = 0; j < assets.length; ++j) { var asset = assets[j]; var match = reSub.exec(asset); if (match !== null) { m = fs.listTree(fs.join(basePath, match[1])); // files are sorted in file-system order. // this makes the order non-portable // we'll be sorting the files now using JS sort // so the order is more consistent across multiple platforms m.sort(); for (i = 0; i < m.length; ++i) { var filename = fs.join(basePath, match[1], m[i]); if (! excludeFile(m[i])) { if (fs.isFile(filename)) { files.push(filename); } } } } else { match = reAll.exec(asset); if (match !== null) { throw new Error("Not implemented"); } else { if (! excludeFile(asset)) { files.push(fs.join(basePath, asset)); } } } } var content = ""; for (i = 0; i < files.length; ++i) { try { var c = fs.read(files[i]); content += c + "\n"; } catch (err) { console.error("Cannot read asset '%s'", files[i]); } } return content; }; //////////////////////////////////////////////////////////////////////////////// /// @brief installs an asset for an app //////////////////////////////////////////////////////////////////////////////// var buildFileAsset = function(app, path, basePath, asset) { var content = buildAssetContent(app, asset.files, basePath); var type; // ............................................................................. // content-type detection // ............................................................................. // contentType explicitly specified for asset if (asset.contentType) { type = asset.contentType; } // path contains a dot, derive content type from path else if (path.match(/\.[a-zA-Z0-9]+$/)) { type = arangodb.guessContentType(path); } // path does not contain a dot, // derive content type from included asset names else if (asset.files.length > 0) { type = arangodb.guessContentType(asset.files[0]); } // use built-in defaulti content-type else { type = arangodb.guessContentType(""); } // ............................................................................. // return content // ............................................................................. return { contentType: type, body: content }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief generates asset action //////////////////////////////////////////////////////////////////////////////// var buildAssetRoute = function (app, path, basePath, asset) { var c = buildFileAsset(app, path, basePath, asset); return { url: { match: path }, content: { contentType: c.contentType, body: c.body } }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief installs the assets of an app //////////////////////////////////////////////////////////////////////////////// var installAssets = function (app, routes) { var path; var desc = app.manifest; if (! desc) { throw new Error("Invalid application manifest"); } var normalized; var route; if (desc.hasOwnProperty('assets')) { for (path in desc.assets) { if (desc.assets.hasOwnProperty(path)) { var asset = desc.assets[path]; var basePath = fs.join(app.root, app.path); if (asset.hasOwnProperty('basePath')) { basePath = asset.basePath; } normalized = arangodb.normalizeURL("/" + path); if (asset.hasOwnProperty('files')) { route = buildAssetRoute(app, normalized, basePath, asset); routes.routes.push(route); } } } } if (desc.hasOwnProperty('files')) { for (path in desc.files) { if (desc.files.hasOwnProperty(path)) { var directory, gzip = false; if (desc.files[path].hasOwnProperty('path')) { directory = desc.files[path].path; } else { directory = desc.files[path]; } if (desc.files[path].hasOwnProperty('gzip')) { if (desc.files[path].gzip === true) { gzip = true; } } normalized = arangodb.normalizeURL("/" + path); route = { url: { match: normalized + "/*" }, action: { "do": "@arangodb/actions/pathHandler", "options": { root: app.root, path: fs.join(app.path, directory), gzip: gzip } } }; routes.routes.push(route); } } } }; //////////////////////////////////////////////////////////////////////////////// /// @brief create middleware matchers //////////////////////////////////////////////////////////////////////////////// var createMiddlewareMatchers = function (rt, routes, controller, prefix) { var j, route; for (j = 0; j < rt.length; ++j) { route = rt[j]; if (route.hasOwnProperty("url")) { route.url.match = arangodb.normalizeURL(prefix + "/" + route.url.match); } route.context = controller; routes.middleware.push(route); } }; //////////////////////////////////////////////////////////////////////////////// /// @brief transform the internal route objects into proper routing callbacks //////////////////////////////////////////////////////////////////////////////// var transformControllerToRoute = function (routeInfo, route, isDevel) { return function (req, res) { var i, errInfo, tmp; try { // Check constraints if (routeInfo.hasOwnProperty("constraints")) { var constraints = routeInfo.constraints; try { _.each({ urlParameters: constraints.urlParams, parameters: constraints.queryParams }, function (paramConstraints, paramsPropertyName) { var params = req[paramsPropertyName]; _.each(paramConstraints, function (constraint, paramName) { var result = constraint.validate(params[paramName]); params[paramName] = result.value; if (result.error) { result.error.message = 'Invalid value for "' + paramName + '": ' + result.error.message; throw result.error; } }); }); } catch (err) { actions.resultBad(req, res, actions.HTTP_BAD, err.message); return; } } // Apply request checks for (i = 0; i < routeInfo.checks.length; ++i) { routeInfo.checks[i].check(req); } // Add Body Params for (i = 0; i < routeInfo.bodyParams.length; ++i) { tmp = routeInfo.bodyParams[i]; req.parameters[tmp.paramName] = tmp.construct(tmp.extract(req)); } routeInfo.callback(req, res); } catch (e) { for (i = 0; i < routeInfo.errorResponses.length; ++i) { errInfo = routeInfo.errorResponses[i]; if ( (typeof errInfo.errorClass === 'string' && e.name === errInfo.errorClass) || (typeof errInfo.errorClass === 'function' && e instanceof errInfo.errorClass) ) { res.status(errInfo.code); if (is.notExisty(errInfo.errorHandler)) { res.json({error: errInfo.reason}); } else { res.json(errInfo.errorHandler(e)); } return; } } // Default Error Handler if (! e.statusCode) { console.errorLines( "Error in foxx route '%s': '%s', Stacktrace:\n%s", route, e.message || String(e), e.stack || "" ); } actions.resultException(req, res, e, undefined, isDevel); } }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief transform the internal route objects into proper routing callbacks //////////////////////////////////////////////////////////////////////////////// var transformRoutes = function (rt, routes, controller, prefix, isDevel) { var j, route; for (j = 0; j < rt.length; ++j) { route = rt[j]; route.action = { callback: transformControllerToRoute(route.action, route.url || "No Route", isDevel) }; if (route.hasOwnProperty("url")) { route.url.match = arangodb.normalizeURL(prefix + "/" + route.url.match); } route.context = controller; routes.routes.push(route); } }; var routeRegEx = /^(\/:?[a-zA-Z0-9_\-%]+)+\/?$/; var validateRoute = function(route) { if (route[0] !== "/") { throw new ArangoError({ errorNum: errors.ERROR_INVALID_MOUNTPOINT.code, errorMessage: "Route has to start with /." }); } if (!routeRegEx.test(route)) { // Controller routes may be /. Foxxes are not allowed to if (route.length !== 1) { throw new ArangoError({ errorNum: errors.ERROR_INVALID_MOUNTPOINT.code, errorMessage: "Route parts '" + route + "' may only contain a-z, A-Z, 0-9 or _. But may start with a :" }); } } }; //////////////////////////////////////////////////////////////////////////////// /// @brief computes the exports of an app //////////////////////////////////////////////////////////////////////////////// var exportApp = function (app) { if (app.needsConfiguration()) { throw new ArangoError({ errorNum: errors.ERROR_APP_NEEDS_CONFIGURATION.code, errorMessage: errors.ERROR_APP_NEEDS_CONFIGURATION.message }); } if (!app.isDevelopment && app.main.loaded) { return app.main.exports; } app.main.exports = {}; // mount all exports if (app.manifest.hasOwnProperty("exports")) { var appExports = app.manifest.exports; if (typeof appExports === "string") { app.main.exports = app.run(appExports); } else if (appExports) { Object.keys(appExports).forEach(function (key) { app.main.exports[key] = app.run(appExports[key]); }); } } app.main.loaded = true; return app.exports; }; //////////////////////////////////////////////////////////////////////////////// ///// @brief escapes all html reserved characters that are not allowed in
////////////////////////////////////////////////////////////////////////////////// function escapeHTML (string) { var list = string.split(""); var i = 0; for (i = 0; i < list.length; ++i) { switch (list[i]) { case "'": list[i] = "'"; break; case '"': list[i] = """; break; case "&": list[i] = "&"; break; case "<": list[i] = "<"; break; case ">": list[i] = ">"; break; default: } } return list.join(""); } //////////////////////////////////////////////////////////////////////////////// /// @brief routes a default Configuration Required app //////////////////////////////////////////////////////////////////////////////// var routeNeedsConfigurationApp = function(app) { return { urlPrefix: "", name: 'foxx("' + app.mount + '")', routes: [{ "internal": true, "url" : { match: "/*", methods: actions.ALL_METHODS }, "action": { "callback": function(req, res) { res.responseCode = actions.HTTP_SERVICE_UNAVAILABLE; res.contentType = "text/html; charset=utf-8"; if (app.isDevelopment) { res.body = "Service Unavailable " + "This service is not configured.
"; res.body += "Configuration Information
"; res.body += ""; res.body += escapeHTML(JSON.stringify(app.getConfiguration(), undefined, 2)); res.body += ""; res.budy += ""; } else { res.body = "Service Unavailable " + "This service is not configured.
"; } return; } } }], middleware: [], context: {}, models: {}, foxx: true }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief routes this app if the original is broken app //////////////////////////////////////////////////////////////////////////////// var routeBrokenApp = function(app, err) { return { urlPrefix: "", name: 'foxx("' + app.mount + '")', routes: [{ "internal": true, "url" : { match: "/*", methods: actions.ALL_METHODS }, "action": { "callback": function(req, res) { res.responseCode = actions.HTTP_SERVICE_UNAVAILABLE; res.contentType = "text/html; charset=utf-8"; if (app.isDevelopment) { var errToPrint = err.cause ? err.cause : err; res.body = "" + escapeHTML(String(errToPrint)) + " " + escapeHTML(String(errToPrint.stack)) + ""; } else { res.body = "Service Unavailable " + "This service is temporarily not available. Please check the log file for errors.
"; } return; } } }], middleware: [], context: {}, models: {}, foxx: true }; }; //////////////////////////////////////////////////////////////////////////////// /// @brief computes the routes of an app //////////////////////////////////////////////////////////////////////////////// var routeApp = function (app, isInstallProcess) { if (app.needsConfiguration()) { return routeNeedsConfigurationApp(app); } var defaultDocument = app.manifest.defaultDocument; // setup the routes var routes = { urlPrefix: "", name: 'foxx("' + app.mount + '")', routes: [], middleware: [], context: {}, models: {}, foxx: true, appContext: { app: app, module: app.main } }; if ((app.mount + defaultDocument) !== app.mount) { // only add redirection if src and target are not the same routes.routes.push({ "url" : { match: "/" }, "action" : { "do" : "@arangodb/actions/redirectRequest", "options" : { "permanently" : !app.isDevelopment, "destination" : defaultDocument, "relative" : true } } }); } // mount all controllers var controllers = app.manifest.controllers; try { if (controllers) { Object.keys(app.moduleCache).forEach(function (filename) { // Clear the module cache to force re-evaluation delete app.moduleCache[filename]; }); Object.keys(controllers).forEach(function (key) { mountController(app, routes, key, controllers[key]); }); } // install all files and assets installAssets(app, routes); // return the new routes return routes; } catch (e) { console.errorLines(`Cannot compute Foxx application routes:\n${e.stack}`); if (isInstallProcess) { throw e; } return routeBrokenApp(app, e); } return null; }; var mountController = function (service, routes, mount, filename) { validateRoute(mount); // set up a context for the application start function var appContext = { prefix: arangodb.normalizeURL(`/${mount}`), // app mount foxxes: [] }; service.run(filename, {appContext, preprocess}); // ............................................................................. // routingInfo // ............................................................................. var foxxes = appContext.foxxes; for (var i = 0; i < foxxes.length; i++) { var foxx = foxxes[i]; var ri = foxx.routingInfo; _.extend(routes.models, foxx.models); if (ri.hasOwnProperty('middleware')) { createMiddlewareMatchers(ri.middleware, routes, mount, ri.urlPrefix); } if (ri.hasOwnProperty('routes')) { transformRoutes(ri.routes, routes, mount, ri.urlPrefix, service.isDevelopment); } } }; exports.exportApp = exportApp; exports.routeApp = routeApp; exports.__test_transformControllerToRoute = transformControllerToRoute;