From 5d028b72fb6cfd45ca15cfd94157d2a1662260ff Mon Sep 17 00:00:00 2001 From: Harry Wolff Date: Fri, 11 Apr 2014 23:46:15 -0400 Subject: [PATCH] Upgrade to Express 4.0 no related issue - Updates package.json packages, adding express middleware packages that have been broken into their own modules - Updates controllers/frontend.js to use the new Layer object that Express 4.0 has. Requires some monkey-patching as the Layer object isn't explicitly surfaced, however it should be safe to do. - Moved the setup of routes into middleware/index.js because they need to be added as a middleware function before the 404 and 500 handlers. This is no longer possible with the old app.use(app.router) as that has been removed. - Cleaned up middleware/index.js to make it compatible with Express 4.0. - Simplified the way themes are activated and enabled when they are activated. The new handling is simpler, yet should still cover all the use cases that previously existed. - The entire flow of activating a theme through middleware should be a little more centralized, letting it be easier to read and maintain. - Moved every routes/*.js file to use an individual express.Router() instance. --- core/server/bookshelf-session.js | 2 +- core/server/controllers/frontend.js | 31 +++++++-- core/server/index.js | 14 +--- core/server/middleware/index.js | 99 ++++++++++++++-------------- core/server/middleware/middleware.js | 4 +- core/server/routes/admin.js | 64 +++++++++--------- core/server/routes/api.js | 56 ++++++++-------- core/server/routes/frontend.js | 28 ++++---- package.json | 9 ++- 9 files changed, 168 insertions(+), 139 deletions(-) diff --git a/core/server/bookshelf-session.js b/core/server/bookshelf-session.js index b1d57794b4..5178716649 100644 --- a/core/server/bookshelf-session.js +++ b/core/server/bookshelf-session.js @@ -1,4 +1,4 @@ -var Store = require('express').session.Store, +var Store = require('express-session').Store, models = require('./models'), time12h = 12 * 60 * 60 * 1000, diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index fddbdcd3ad..a39b001038 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -9,7 +9,6 @@ var moment = require('moment'), _ = require('lodash'), url = require('url'), when = require('when'), - Route = require('express').Route, api = require('../api'), config = require('../config'), @@ -18,8 +17,32 @@ var moment = require('moment'), errors = require('../errors'), frontendControllers, - // Cache static post permalink regex - staticPostPermalink = new Route(null, '/:slug/:edit?'); + staticPostPermalink, + oldRoute, + dummyRouter = require('express').Router(); + +// Overload this dummyRouter as we only want the layer object. +// We don't want to keep in memory many items in an array so we +// clear the stack array after every invocation. +oldRoute = dummyRouter.route; +dummyRouter.route = function () { + var layer; + + // Apply old route method + oldRoute.apply(dummyRouter, arguments); + + // Grab layer object + layer = dummyRouter.stack[0]; + + // Reset stack array for memory purposes + dummyRouter.stack = []; + + // Return layer + return layer; +}; + +// Cache static post permalink regex +staticPostPermalink = dummyRouter.route('/:slug/:edit?'); function getPostPage(options) { return api.settings.read('postsPerPage').then(function (response) { @@ -155,7 +178,7 @@ frontendControllers = { editFormat = permalink.value[permalink.value.length - 1] === '/' ? ':edit?' : '/:edit?'; // Convert saved permalink into an express Route object - permalink = new Route(null, permalink.value + editFormat); + permalink = dummyRouter.route(permalink.value + editFormat); // Check if the path matches the permalink structure. // diff --git a/core/server/index.js b/core/server/index.js index d8d20a70f3..ff2a7b37a1 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -19,7 +19,6 @@ var crypto = require('crypto'), models = require('./models'), permissions = require('./permissions'), apps = require('./apps'), - routes = require('./routes'), packageInfo = require('../../package.json'), // Variables @@ -265,20 +264,9 @@ function init(server) { // Load helpers helpers.loadCoreHelpers(adminHbs, assetHash); - // ## Middleware + // ## Middleware and Routing middleware(server, dbHash); - // ## Routing - - // Set up API routes - routes.api(server); - - // Set up Admin routes - routes.admin(server); - - // Set up Frontend routes - routes.frontend(server); - // Log all theme errors and warnings _.each(config().paths.availableThemes._messages.errors, function (error) { errors.logError(error.message, error.context, error.help); diff --git a/core/server/middleware/index.js b/core/server/middleware/index.js index f1e807d1af..6ed583dbde 100644 --- a/core/server/middleware/index.js +++ b/core/server/middleware/index.js @@ -4,14 +4,20 @@ var api = require('../api'), BSStore = require('../bookshelf-session'), + bodyParser = require('body-parser'), config = require('../config'), + cookieParser = require('cookie-parser'), errors = require('../errors'), express = require('express'), + favicon = require('static-favicon'), fs = require('fs'), hbs = require('express-hbs'), + logger = require('morgan'), middleware = require('./middleware'), packageInfo = require('../../../package.json'), path = require('path'), + routes = require('../routes'), + session = require('express-session'), slashes = require('connect-slashes'), storage = require('../storage'), url = require('url'), @@ -81,45 +87,14 @@ function initThemeData(secure) { return themeConfig; } -// ### InitViews Middleware -// Initialise Theme or Admin Views -function initViews(req, res, next) { - /*jslint unparam:true*/ - - if (!res.isAdmin) { - var themeData = initThemeData(req.secure); - hbs.updateTemplateOptions({ data: {blog: themeData} }); - expressServer.engine('hbs', expressServer.get('theme view engine')); - expressServer.set('views', path.join(config().paths.themePath, expressServer.get('activeTheme'))); - } else { - expressServer.engine('hbs', expressServer.get('admin view engine')); - expressServer.set('views', config().paths.adminViews); - } - - // Pass 'secure' flag to the view engine - // so that templates can choose 'url' vs 'urlSSL' - res.locals.secure = req.secure; - - next(); -} - // ### Activate Theme // Helper for manageAdminAndTheme function activateTheme(activeTheme) { var hbsOptions, - themePartials = path.join(config().paths.themePath, activeTheme, 'partials'), - stackLocation = _.indexOf(expressServer.stack, _.find(expressServer.stack, function (stackItem) { - return stackItem.route === config().paths.subdir && stackItem.handle.name === 'settingEnabled'; - })); + themePartials = path.join(config().paths.themePath, activeTheme, 'partials'); // clear the view cache expressServer.cache = {}; - expressServer.disable(expressServer.get('activeTheme')); - expressServer.set('activeTheme', activeTheme); - expressServer.enable(expressServer.get('activeTheme')); - if (stackLocation) { - expressServer.stack[stackLocation].handle = middleware.whenEnabled(expressServer.get('activeTheme'), middleware.staticTheme()); - } // set view engine hbsOptions = { partialsDir: [ config().paths.helperTemplates ] }; @@ -135,24 +110,43 @@ function activateTheme(activeTheme) { // Update user error template errors.updateActiveTheme(activeTheme); + + // Set active theme variable on the express server + expressServer.set('activeTheme', activeTheme); } - // ### ManageAdminAndTheme Middleware +// ### decideContext Middleware // Uses the URL to detect whether this response should be an admin response // This is used to ensure the right content is served, and is not for security purposes -function manageAdminAndTheme(req, res, next) { +function decideContext(req, res, next) { res.isAdmin = req.url.lastIndexOf(config().paths.subdir + '/ghost/', 0) === 0; if (res.isAdmin) { expressServer.enable('admin'); - expressServer.disable(expressServer.get('activeTheme')); + expressServer.engine('hbs', expressServer.get('admin view engine')); + expressServer.set('views', config().paths.adminViews); } else { - expressServer.enable(expressServer.get('activeTheme')); expressServer.disable('admin'); + var themeData = initThemeData(req.secure); + hbs.updateTemplateOptions({ data: {blog: themeData} }); + expressServer.engine('hbs', expressServer.get('theme view engine')); + expressServer.set('views', path.join(config().paths.themePath, expressServer.get('activeTheme'))); } + + // Pass 'secure' flag to the view engine + // so that templates can choose 'url' vs 'urlSSL' + res.locals.secure = req.secure; + + next(); +} + +// ### updateActiveTheme +// Updates the expressServer's activeTheme variable and subsequently +// activates that theme's views with the hbs templating engine if it +// is not yet activated. +function updateActiveTheme(req, res, next) { api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function (response) { var activeTheme = response.settings[0]; - // Check if the theme changed if (activeTheme.value !== expressServer.get('activeTheme')) { // Change theme @@ -285,14 +279,14 @@ module.exports = function (server, dbHash) { // Logging configuration if (logging !== false) { if (expressServer.get('env') !== 'development') { - expressServer.use(express.logger(logging || {})); + expressServer.use(logger(logging || {})); } else { - expressServer.use(express.logger(logging || 'dev')); + expressServer.use(logger(logging || 'dev')); } } // Favicon - expressServer.use(subdir, express.favicon(corePath + '/shared/favicon.ico')); + expressServer.use(subdir, favicon(corePath + '/shared/favicon.ico')); // Static assets expressServer.use(subdir + '/shared', express['static'](path.join(corePath, '/shared'), {maxAge: ONE_HOUR_MS})); @@ -301,7 +295,8 @@ module.exports = function (server, dbHash) { expressServer.use(subdir + '/public', express['static'](path.join(corePath, '/built/public'), {maxAge: ONE_YEAR_MS})); // First determine whether we're serving admin or theme content - expressServer.use(manageAdminAndTheme); + expressServer.use(updateActiveTheme); + expressServer.use(decideContext); // Admin only config expressServer.use(subdir + '/ghost', middleware.whenEnabled('admin', express['static'](path.join(corePath, '/clientold/assets'), {maxAge: ONE_YEAR_MS}))); @@ -314,7 +309,7 @@ module.exports = function (server, dbHash) { expressServer.use(checkSSL); // Theme only config - expressServer.use(subdir, middleware.whenEnabled(expressServer.get('activeTheme'), middleware.staticTheme())); + expressServer.use(subdir, middleware.staticTheme()); // Serve robots.txt if not found in theme expressServer.use(robots()); @@ -323,8 +318,8 @@ module.exports = function (server, dbHash) { expressServer.use(slashes(true, {headers: {'Cache-Control': 'public, max-age=' + ONE_YEAR_S}})); // Body parsing - expressServer.use(express.json()); - expressServer.use(express.urlencoded()); + expressServer.use(bodyParser.json()); + expressServer.use(bodyParser.urlencoded()); // ### Sessions // we need the trailing slash in the cookie path. Session handling *must* be after the slash handling @@ -339,8 +334,8 @@ module.exports = function (server, dbHash) { cookie.secure = true; } - expressServer.use(express.cookieParser()); - expressServer.use(express.session({ + expressServer.use(cookieParser()); + expressServer.use(session({ store: new BSStore(), proxy: true, secret: dbHash, @@ -366,11 +361,15 @@ module.exports = function (server, dbHash) { // ToDo: Remove when ember handles passive notifications. expressServer.use(middleware.cleanNotifications); - // Initialise the views - expressServer.use(initViews); - // ### Routing - expressServer.use(subdir, expressServer.router); + // Set up API routes + expressServer.use(subdir, routes.api(middleware)); + + // Set up Admin routes + expressServer.use(subdir, routes.admin(middleware)); + + // Set up Frontend routes + expressServer.use(subdir, routes.frontend()); // ### Error handling // 404 Handler diff --git a/core/server/middleware/middleware.js b/core/server/middleware/middleware.js index 5c3eeeea06..21f2b61deb 100644 --- a/core/server/middleware/middleware.js +++ b/core/server/middleware/middleware.js @@ -3,6 +3,7 @@ // middleware_spec.js var _ = require('lodash'), + csrf = require('csurf'), express = require('express'), busboy = require('./ghost-busboy'), config = require('../config'), @@ -185,10 +186,9 @@ var middleware = { }, conditionalCSRF: function (req, res, next) { - var csrf = express.csrf(); // CSRF is needed for admin only if (res.isAdmin) { - csrf(req, res, next); + csrf()(req, res, next); return; } next(); diff --git a/core/server/routes/admin.js b/core/server/routes/admin.js index 5e79c6ee60..9020e56e25 100644 --- a/core/server/routes/admin.js +++ b/core/server/routes/admin.js @@ -1,73 +1,77 @@ var admin = require('../controllers/admin'), config = require('../config'), - middleware = require('../middleware').middleware, + express = require('express'), ONE_HOUR_S = 60 * 60, ONE_YEAR_S = 365 * 24 * ONE_HOUR_S, adminRoutes; -adminRoutes = function (server) { +adminRoutes = function (middleware) { + var router = express.Router(), + subdir = config().paths.subdir; + // Have ember route look for hits first // to prevent conflicts with pre-existing routes - server.get('/ghost/ember/*', middleware.redirectToSignup, admin.index); + router.get('/ghost/ember/*', middleware.redirectToSignup, admin.index); - var subdir = config().paths.subdir; // ### Admin routes - server.get('/logout/', function redirect(req, res) { + router.get('/logout/', function redirect(req, res) { /*jslint unparam:true*/ res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S}); res.redirect(301, subdir + '/ghost/signout/'); }); - server.get('/signout/', function redirect(req, res) { + router.get('/signout/', function redirect(req, res) { /*jslint unparam:true*/ res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S}); res.redirect(301, subdir + '/ghost/signout/'); }); - server.get('/signin/', function redirect(req, res) { + router.get('/signin/', function redirect(req, res) { /*jslint unparam:true*/ res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S}); res.redirect(301, subdir + '/ghost/signin/'); }); - server.get('/signup/', function redirect(req, res) { + router.get('/signup/', function redirect(req, res) { /*jslint unparam:true*/ res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S}); res.redirect(301, subdir + '/ghost/signup/'); }); - server.get('/ghost/signout/', admin.signout); - server.post('/ghost/signout/', admin.doSignout); - server.get('/ghost/signin/', middleware.redirectToSignup, middleware.redirectToDashboard, admin.signin); - server.post('/ghost/signin/', admin.doSignin); - server.get('/ghost/signup/', middleware.redirectToDashboard, admin.signup); - server.post('/ghost/signup/', admin.doSignup); - server.get('/ghost/forgotten/', middleware.redirectToDashboard, admin.forgotten); - server.post('/ghost/forgotten/', admin.doForgotten); - server.get('/ghost/reset/:token', admin.reset); - server.post('/ghost/reset/:token', admin.doReset); - server.post('/ghost/changepw/', admin.doChangePassword); + router.get('/ghost/signout/', admin.signout); + router.post('/ghost/signout/', admin.doSignout); + router.get('/ghost/signin/', middleware.redirectToSignup, middleware.redirectToDashboard, admin.signin); + router.post('/ghost/signin/', admin.doSignin); + router.get('/ghost/signup/', middleware.redirectToDashboard, admin.signup); + router.post('/ghost/signup/', admin.doSignup); + router.get('/ghost/forgotten/', middleware.redirectToDashboard, admin.forgotten); + router.post('/ghost/forgotten/', admin.doForgotten); + router.get('/ghost/reset/:token', admin.reset); + router.post('/ghost/reset/:token', admin.doReset); + router.post('/ghost/changepw/', admin.doChangePassword); - server.get('/ghost/editor/:id/:action', admin.editor); - server.get('/ghost/editor/:id/', admin.editor); - server.get('/ghost/editor/', admin.editor); - server.get('/ghost/content/', admin.content); - server.get('/ghost/settings*', admin.settings); - server.get('/ghost/debug/', admin.debug.index); + router.get('/ghost/editor/:id/:action', admin.editor); + router.get('/ghost/editor/:id/', admin.editor); + router.get('/ghost/editor/', admin.editor); + router.get('/ghost/content/', admin.content); + router.get('/ghost/settings*', admin.settings); + router.get('/ghost/debug/', admin.debug.index); - server.get('/ghost/export/', admin.debug.exportContent); + router.get('/ghost/export/', admin.debug.exportContent); - server.post('/ghost/upload/', middleware.busboy, admin.upload); + router.post('/ghost/upload/', middleware.busboy, admin.upload); // redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc. - server.get(/\/((ghost-admin|admin|wp-admin|dashboard|signin)\/?)$/, function (req, res) { + router.get(/\/((ghost-admin|admin|wp-admin|dashboard|signin)\/?)$/, function (req, res) { /*jslint unparam:true*/ res.redirect(subdir + '/ghost/'); }); - server.get(/\/ghost$/, function (req, res) { + router.get(/\/ghost$/, function (req, res) { /*jslint unparam:true*/ res.redirect(subdir + '/ghost/'); }); - server.get('/ghost/', admin.indexold); + router.get('/ghost/', admin.indexold); + + return router; }; module.exports = adminRoutes; \ No newline at end of file diff --git a/core/server/routes/api.js b/core/server/routes/api.js index 6879fb97a6..593e56ae46 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -1,42 +1,46 @@ // # API routes -var middleware = require('../middleware').middleware, +var express = require('express'), api = require('../api'), apiRoutes; -apiRoutes = function (server) { +apiRoutes = function (middleware) { + var router = express.Router(); + // ## Posts - server.get('/ghost/api/v0.1/posts', api.http(api.posts.browse)); - server.post('/ghost/api/v0.1/posts', api.http(api.posts.add)); - server.get('/ghost/api/v0.1/posts/:id(\\d+)', api.http(api.posts.read)); - server.get('/ghost/api/v0.1/posts/:slug([a-z-]+)', api.http(api.posts.read)); - server.put('/ghost/api/v0.1/posts/:id', api.http(api.posts.edit)); - server.del('/ghost/api/v0.1/posts/:id', api.http(api.posts.destroy)); + router.get('/ghost/api/v0.1/posts', api.http(api.posts.browse)); + router.post('/ghost/api/v0.1/posts', api.http(api.posts.add)); + router.get('/ghost/api/v0.1/posts/:id(\\d+)', api.http(api.posts.read)); + router.get('/ghost/api/v0.1/posts/:slug([a-z-]+)', api.http(api.posts.read)); + router.put('/ghost/api/v0.1/posts/:id', api.http(api.posts.edit)); + router['delete']('/ghost/api/v0.1/posts/:id', api.http(api.posts.destroy)); // ## Settings - server.get('/ghost/api/v0.1/settings/', api.http(api.settings.browse)); - server.get('/ghost/api/v0.1/settings/:key/', api.http(api.settings.read)); - server.put('/ghost/api/v0.1/settings/', api.http(api.settings.edit)); + router.get('/ghost/api/v0.1/settings/', api.http(api.settings.browse)); + router.get('/ghost/api/v0.1/settings/:key/', api.http(api.settings.read)); + router.put('/ghost/api/v0.1/settings/', api.http(api.settings.edit)); // ## Users - server.get('/ghost/api/v0.1/users/', api.http(api.users.browse)); - server.get('/ghost/api/v0.1/users/:id/', api.http(api.users.read)); - server.put('/ghost/api/v0.1/users/:id/', api.http(api.users.edit)); + router.get('/ghost/api/v0.1/users/', api.http(api.users.browse)); + router.get('/ghost/api/v0.1/users/:id/', api.http(api.users.read)); + router.put('/ghost/api/v0.1/users/:id/', api.http(api.users.edit)); // ## Tags - server.get('/ghost/api/v0.1/tags/', api.http(api.tags.browse)); + router.get('/ghost/api/v0.1/tags/', api.http(api.tags.browse)); // ## Themes - server.get('/ghost/api/v0.1/themes/', api.http(api.themes.browse)); - server.put('/ghost/api/v0.1/themes/:name', api.http(api.themes.edit)); + router.get('/ghost/api/v0.1/themes/', api.http(api.themes.browse)); + router.put('/ghost/api/v0.1/themes/:name', api.http(api.themes.edit)); // ## Notifications - server.get('/ghost/api/v0.1/notifications/', api.http(api.notifications.browse)); - server.post('/ghost/api/v0.1/notifications/', api.http(api.notifications.add)); - server.del('/ghost/api/v0.1/notifications/:id', api.http(api.notifications.destroy)); + router.get('/ghost/api/v0.1/notifications/', api.http(api.notifications.browse)); + router.post('/ghost/api/v0.1/notifications/', api.http(api.notifications.add)); + router['delete']('/ghost/api/v0.1/notifications/:id', api.http(api.notifications.destroy)); // ## DB - server.get('/ghost/api/v0.1/db/', api.http(api.db.exportContent)); - server.post('/ghost/api/v0.1/db/', middleware.busboy, api.http(api.db.importContent)); - server.del('/ghost/api/v0.1/db/', api.http(api.db.deleteAllContent)); + router.get('/ghost/api/v0.1/db/', api.http(api.db.exportContent)); + router.post('/ghost/api/v0.1/db/', middleware.busboy, api.http(api.db.importContent)); + router['delete']('/ghost/api/v0.1/db/', api.http(api.db.deleteAllContent)); // ## Mail - server.post('/ghost/api/v0.1/mail', api.http(api.mail.send)); - server.post('/ghost/api/v0.1/mail/test', api.http(api.mail.sendTest)); + router.post('/ghost/api/v0.1/mail', api.http(api.mail.send)); + router.post('/ghost/api/v0.1/mail/test', api.http(api.mail.sendTest)); // #### Slugs - server.get('/ghost/api/v0.1/slugs/:type/:name', api.http(api.slugs.generate)); + router.get('/ghost/api/v0.1/slugs/:type/:name', api.http(api.slugs.generate)); + + return router; }; module.exports = apiRoutes; diff --git a/core/server/routes/frontend.js b/core/server/routes/frontend.js index 91b471abc3..f0aed39df6 100644 --- a/core/server/routes/frontend.js +++ b/core/server/routes/frontend.js @@ -1,31 +1,35 @@ var frontend = require('../controllers/frontend'), config = require('../config'), + express = require('express'), ONE_HOUR_S = 60 * 60, ONE_YEAR_S = 365 * 24 * ONE_HOUR_S, frontendRoutes; -frontendRoutes = function (server) { - var subdir = config().paths.subdir; +frontendRoutes = function () { + var router = express.Router(), + subdir = config().paths.subdir; // ### Frontend routes - server.get('/rss/', frontend.rss); - server.get('/rss/:page/', frontend.rss); - server.get('/feed/', function redirect(req, res) { + router.get('/rss/', frontend.rss); + router.get('/rss/:page/', frontend.rss); + router.get('/feed/', function redirect(req, res) { /*jshint unused:true*/ res.set({'Cache-Control': 'public, max-age=' + ONE_YEAR_S}); res.redirect(301, subdir + '/rss/'); }); - server.get('/tag/:slug/rss/', frontend.rss); - server.get('/tag/:slug/rss/:page/', frontend.rss); - server.get('/tag/:slug/page/:page/', frontend.tag); - server.get('/tag/:slug/', frontend.tag); - server.get('/page/:page/', frontend.homepage); - server.get('/', frontend.homepage); - server.get('*', frontend.single); + router.get('/tag/:slug/rss/', frontend.rss); + router.get('/tag/:slug/rss/:page/', frontend.rss); + router.get('/tag/:slug/page/:page/', frontend.tag); + router.get('/tag/:slug/', frontend.tag); + router.get('/page/:page/', frontend.homepage); + router.get('/', frontend.homepage); + router.get('*', frontend.single); + + return router; }; module.exports = frontendRoutes; \ No newline at end of file diff --git a/package.json b/package.json index cdfe35b2f1..82eb0b1432 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,24 @@ "engineStrict": true, "dependencies": { "bcryptjs": "0.7.10", + "body-parser": "1.0.2", "bookshelf": "0.6.8", "busboy": "0.0.12", "colors": "0.6.2", "compression": "^1.0.2", + "cookie-parser": "1.0.1", + "connect": "3.0.0-rc.1", "connect-slashes": "1.2.0", + "csurf": "1.1.0", "downsize": "0.0.5", - "express": "3.4.6", + "express": "4.1.1", "express-hbs": "0.7.10", + "express-session": "1.0.4", "fs-extra": "0.8.1", "knex": "0.5.8", "lodash": "2.4.1", "moment": "2.4.0", + "morgan": "1.0.0", "node-polyglot": "0.3.0", "node-uuid": "1.4.1", "nodemailer": "0.5.13", @@ -51,6 +57,7 @@ "semver": "2.2.1", "showdown": "https://github.com/ErisDS/showdown/archive/v0.3.2-ghost.tar.gz", "sqlite3": "2.2.3", + "static-favicon": "1.0.2", "unidecode": "0.1.3", "validator": "3.4.0", "when": "2.7.0",