'use strict' // app // external modules var express = require('express') var ejs = require('ejs') var passport = require('passport') var methodOverride = require('method-override') var cookieParser = require('cookie-parser') var compression = require('compression') var session = require('express-session') var SequelizeStore = require('connect-session-sequelize')(session.Store) var fs = require('fs') var path = require('path') var uuid = require('uuid') var morgan = require('morgan') var passportSocketIo = require('passport.socketio') var helmet = require('helmet') var i18n = require('i18n') var flash = require('connect-flash') // core var config = require('./lib/config') var logger = require('./lib/logger') var response = require('./lib/response') var models = require('./lib/models') // generate front-end constants by template var constpath = path.join(__dirname, './public/js/lib/common/constant.ejs') var data = { domain: config.domain, urlpath: config.urlpath, debug: config.debug, version: config.version, GOOGLE_API_KEY: config.google.clientSecret, GOOGLE_CLIENT_ID: config.google.clientID, DROPBOX_APP_KEY: config.dropbox.clientSecret } ejs.renderFile(constpath, data, {}, function (err, str) { if (err) throw new Error(err) fs.writeFileSync(path.join(__dirname, './public/build/constant.js'), str) }) // server setup var app = express() var server = null if (config.usessl) { var ca = (function () { var i, len, results results = [] for (i = 0, len = config.sslcapath.length; i < len; i++) { results.push(fs.readFileSync(config.sslcapath[i], 'utf8')) } return results })() var options = { key: fs.readFileSync(config.sslkeypath, 'utf8'), cert: fs.readFileSync(config.sslcertpath, 'utf8'), ca: ca, dhparam: fs.readFileSync(config.dhparampath, 'utf8'), requestCert: false, rejectUnauthorized: false } server = require('https').createServer(options, app) } else { server = require('http').createServer(app) } // logger app.use(morgan('combined', { 'stream': logger })) // socket io var io = require('socket.io')(server) io.engine.ws = new (require('uws').Server)({ noServer: true, perMessageDeflate: false }) // others var realtime = require('./lib/realtime.js') // assign socket io to realtime realtime.io = io // methodOverride app.use(methodOverride('_method')) // session store var sessionStore = new SequelizeStore({ db: models.sequelize }) // compression app.use(compression()) // use hsts to tell https users stick to this if (config.hsts.enable) { app.use(helmet.hsts({ maxAge: config.hsts.maxAgeSeconds * 1000, includeSubdomains: config.hsts.includeSubdomains, preload: config.hsts.preload })) } else if (config.usessl) { logger.info('Consider enabling HSTS for extra security:') logger.info('https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security') } app.use((req, res, next) => { res.locals.nonce = uuid.v4() next() }) // use Content-Security-Policy to limit XSS, dangerous plugins, etc. // https://helmetjs.github.io/docs/csp/ function getCspNonce (req, res) { return "'nonce-" + res.locals.nonce + "'" } function getCspWebSocketUrl (req, res) { // wss: is included in 'self', but 'ws:' is not return (req.protocol === 'http' ? 'ws:' : 'wss:') + config.serverurl.replace(/https?:/, "") } function mergeWithDefaults(configured, defaultDirective, cdnDirective) { var directive = [].concat(configured) if (config.csp.addDefaults && defaultDirective) { directive = directive.concat(defaultDirective) } if (config.usecdn && cdnDirective) { directive = directive.concat(cdnDirective) } return directive } if (config.csp.enable) { var defaultDirectives = { defaultSrc: ['\'self\''], scriptSrc: ['\'self\'', 'vimeo.com', 'https://gist.github.com', 'www.slideshare.net', 'https://query.yahooapis.com', 'https://*.disqus.com', '\'unsafe-eval\''], // TODO: Remove unsafe-eval - webpack script-loader issues imgSrc: ['*'], styleSrc: ['\'self\'', '\'unsafe-inline\'', 'https://assets-cdn.github.com'], // unsafe-inline is required for some libs, plus used in views fontSrc: ['\'self\'', 'https://public.slidesharecdn.com'], objectSrc: ['*'], // Chrome PDF viewer treats PDFs as objects :/ childSrc: ['*'], connectSrc: ['\'self\'', 'https://links.services.disqus.com', 'wss://realtime.services.disqus.com'] }; var cdnDirectives = { scriptSrc: ['https://cdnjs.cloudflare.com', 'https://cdn.mathjax.org'], styleSrc: ['https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com'], fontSrc: ['https://cdnjs.cloudflare.com', 'https://fonts.gstatic.com'] } var directives = {} for (var propertyName in config.csp.directives) { if (config.csp.directives.hasOwnProperty(propertyName)) { directives[propertyName] = mergeWithDefaults( config.csp.directives[propertyName], defaultDirectives[propertyName], cdnDirectives[propertyName] ) } } for (var propertyName in defaultDirectives) { if (!directives[propertyName]) { directives[propertyName] = mergeWithDefaults( [], defaultDirectives[propertyName], cdnDirectives[propertyName] ) } } directives.scriptSrc.push(getCspNonce) directives.connectSrc.push(getCspWebSocketUrl) if (config.csp.upgradeInsecureRequests === 'auto') { directives.upgradeInsecureRequests = config.usessl === 'true' } else { directives.upgradeInsecureRequests = config.csp.upgradeInsecureRequests === 'true' } app.use(helmet.contentSecurityPolicy({ directives: directives })) } else { logger.info('Content-Security-Policy is disabled. This may be a security risk.') } i18n.configure({ locales: ['en', 'zh', 'fr', 'de', 'ja', 'es', 'ca', 'el', 'pt', 'it', 'tr', 'ru', 'nl', 'hr', 'pl', 'uk', 'hi', 'sv', 'eo', 'da'], cookie: 'locale', directory: path.join(__dirname, '/locales') }) app.use(cookieParser()) app.use(i18n.init) // routes without sessions // static files app.use('/', express.static(path.join(__dirname, '/public'), { maxAge: config.staticcachetime })) // session app.use(session({ name: config.sessionname, secret: config.sessionsecret, resave: false, // don't save session if unmodified saveUninitialized: true, // always create session to ensure the origin rolling: true, // reset maxAge on every response cookie: { maxAge: config.sessionlife }, store: sessionStore })) // session resumption var tlsSessionStore = {} server.on('newSession', function (id, data, cb) { tlsSessionStore[id.toString('hex')] = data cb() }) server.on('resumeSession', function (id, cb) { cb(null, tlsSessionStore[id.toString('hex')] || null) }) // middleware which blocks requests when we're too busy app.use(require('./lib/web/middleware/tooBusy')) app.use(flash()) // passport app.use(passport.initialize()) app.use(passport.session()) // check uri is valid before going further app.use(require('./lib/web/middleware/checkURIValid')) // redirect url without trailing slashes app.use(require('./lib/web/middleware/redirectWithoutTrailingSlashes')) // routes need sessions // template files app.set('views', path.join(__dirname, '/public/views')) // set render engine app.engine('ejs', ejs.renderFile) // set view engine app.set('view engine', 'ejs') app.use(require('./lib/web/baseRouter')) app.use(require('./lib/web/statusRouter')) app.use(require('./lib/web/auth')) app.use(require('./lib/web/historyRouter')) app.use(require('./lib/web/userRouter')) app.use(require('./lib/web/imageRouter')) app.use(require('./lib/web/noteRouter')) // response not found if no any route matxches app.get('*', function (req, res) { response.errorNotFound(res) }) // socket.io secure io.use(realtime.secure) // socket.io auth io.use(passportSocketIo.authorize({ cookieParser: cookieParser, key: config.sessionname, secret: config.sessionsecret, store: sessionStore, success: realtime.onAuthorizeSuccess, fail: realtime.onAuthorizeFail })) // socket.io heartbeat io.set('heartbeat interval', config.heartbeatinterval) io.set('heartbeat timeout', config.heartbeattimeout) // socket.io connection io.sockets.on('connection', realtime.connection) // listen function startListen () { server.listen(config.port, function () { var schema = config.usessl ? 'HTTPS' : 'HTTP' logger.info('%s Server listening at port %d', schema, config.port) realtime.maintenance = false }) } // sync db then start listen models.sequelize.sync().then(function () { // check if realtime is ready if (realtime.isReady()) { models.Revision.checkAllNotesRevision(function (err, notes) { if (err) throw new Error(err) if (!notes || notes.length <= 0) return startListen() }) } else { throw new Error('server still not ready after db synced') } }) // log uncaught exception process.on('uncaughtException', function (err) { logger.error('An uncaught exception has occured.') logger.error(err) logger.error('Process will exit now.') process.exit(1) }) // install exit handler function handleTermSignals () { logger.info('hackmd has been killed by signal, try to exit gracefully...') realtime.maintenance = true // disconnect all socket.io clients Object.keys(io.sockets.sockets).forEach(function (key) { var socket = io.sockets.sockets[key] // notify client server going into maintenance status socket.emit('maintenance') setTimeout(function () { socket.disconnect(true) }, 0) }) var checkCleanTimer = setInterval(function () { if (realtime.isReady()) { models.Revision.checkAllNotesRevision(function (err, notes) { if (err) return logger.error(err) if (!notes || notes.length <= 0) { clearInterval(checkCleanTimer) return process.exit(0) } }) } }, 100) } process.on('SIGINT', handleTermSignals) process.on('SIGTERM', handleTermSignals) process.on('SIGQUIT', handleTermSignals)