move app.js to lib/

update

Signed-off-by: Raccoon <raccoon@hackmd.io>
This commit is contained in:
Raccoon 2021-06-12 13:54:19 +08:00
parent f04e63279c
commit 6f93e43978
No known key found for this signature in database
GPG Key ID: 06770355DC9ECD38
11 changed files with 362 additions and 338 deletions

1
.gitignore vendored
View File

@ -32,3 +32,4 @@ public/uploads/*
.vscode/settings.json
/dist
lib/**/*.js

330
app.js
View File

@ -1,331 +1,3 @@
'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 session = require('express-session')
var SequelizeStore = require('connect-session-sequelize')(session.Store)
var fs = require('fs')
var path = require('path')
var morgan = require('morgan')
var passportSocketIo = require('passport.socketio')
var helmet = require('helmet')
var i18n = require('i18n')
var flash = require('connect-flash')
var apiMetrics = require('prometheus-api-metrics')
// core
var config = require('./dist/config')
var logger = require('./dist/logger')
var response = require('./dist/response')
var models = require('./dist/models')
var csp = require('./dist/csp')
const { Environment } = require('./dist/config/enum')
const { versionCheckMiddleware, checkVersion } = require('./dist/web/middleware/checkVersion')
function createHttpServer () {
if (config.useSSL) {
const ca = (function () {
let i, len
const results = []
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
}
return results
})()
const 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
}
return require('https').createServer(options, app)
} else {
return require('http').createServer(app)
}
}
// server setup
var app = express()
var server = createHttpServer()
// API and process monitoring with Prometheus for Node.js micro-service
app.use(apiMetrics({
metricsPath: '/metrics/router',
excludeRoutes: ['/metrics/codimd']
}))
// logger
app.use(morgan('combined', {
stream: logger.stream
}))
// socket io
var io = require('socket.io')(server)
io.engine.ws = new (require('ws').Server)({
noServer: true,
perMessageDeflate: false
})
// others
var realtime = require('./dist/realtime/realtime.js')
// assign socket io to realtime
realtime.io = io
// methodOverride
app.use(methodOverride('_method'))
// session store
var sessionStore = new SequelizeStore({
db: models.sequelize
})
// use hsts to tell https users stick to this
if (config.hsts.enable) {
app.use(helmet.hsts({
maxAge: config.hsts.maxAgeSeconds,
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')
}
// Add referrer policy to improve privacy
app.use(
helmet.referrerPolicy({
policy: 'same-origin'
})
)
// Generate a random nonce per request, for CSP with inline scripts
app.use(csp.addNonceToLocals)
// use Content-Security-Policy to limit XSS, dangerous plugins, etc.
// https://helmetjs.github.io/docs/csp/
if (config.csp.enable) {
app.use(helmet.contentSecurityPolicy({
directives: csp.computeDirectives()
}))
} else {
logger.info('Content-Security-Policy is disabled. This may be a security risk.')
}
i18n.configure({
locales: ['en', 'zh-CN', 'zh-TW', 'fr', 'de', 'ja', 'es', 'ca', 'el', 'pt', 'it', 'tr', 'ru', 'nl', 'hr', 'pl', 'uk', 'hi', 'sv', 'eo', 'da', 'ko', 'id', 'sr'],
cookie: 'locale',
directory: path.join(__dirname, '/locales'),
updateFiles: config.updateI18nFiles
})
app.use(cookieParser())
app.use(i18n.init)
// routes without sessions
// static files
app.use('/', express.static(path.join(__dirname, '/public'), { maxAge: config.staticCacheTime, index: false }))
app.use('/docs', express.static(path.resolve(__dirname, config.docsPath), { maxAge: config.staticCacheTime }))
app.use('/uploads', express.static(path.resolve(__dirname, config.uploadsPath), { maxAge: config.staticCacheTime }))
app.use('/default.md', express.static(path.resolve(__dirname, config.defaultNotePath), { maxAge: config.staticCacheTime }))
app.use(require('./dist/metrics').router)
// 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('./dist/middleware/tooBusy'))
app.use(flash())
// passport
app.use(passport.initialize())
app.use(passport.session())
// check uri is valid before going further
app.use(require('./dist/middleware/checkURIValid'))
// redirect url without trailing slashes
app.use(require('./dist/middleware/redirectWithoutTrailingSlashes'))
app.use(require('./dist/middleware/codiMDVersion'))
if (config.autoVersionCheck && process.env.NODE_ENV === Environment.production) {
checkVersion(app)
app.use(versionCheckMiddleware)
}
// routes need sessions
// template files
app.set('views', config.viewPath)
// set render engine
app.engine('ejs', ejs.renderFile)
// set view engine
app.set('view engine', 'ejs')
// set generally available variables for all views
app.locals.useCDN = config.useCDN
app.locals.serverURL = config.serverURL
app.locals.sourceURL = config.sourceURL
app.locals.privacyPolicyURL = config.privacyPolicyURL
app.locals.allowAnonymous = config.allowAnonymous
app.locals.allowAnonymousEdits = config.allowAnonymousEdits
app.locals.permission = config.permission
app.locals.allowPDFExport = config.allowPDFExport
app.locals.authProviders = {
facebook: config.isFacebookEnable,
twitter: config.isTwitterEnable,
github: config.isGitHubEnable,
bitbucket: config.isBitbucketEnable,
gitlab: config.isGitLabEnable,
mattermost: config.isMattermostEnable,
dropbox: config.isDropboxEnable,
google: config.isGoogleEnable,
ldap: config.isLDAPEnable,
ldapProviderName: config.ldap.providerName,
saml: config.isSAMLEnable,
oauth2: config.isOAuth2Enable,
oauth2ProviderName: config.oauth2.providerName,
openID: config.isOpenIDEnable,
email: config.isEmailEnable,
allowEmailRegister: config.allowEmailRegister
}
app.locals.versionInfo = {
latest: true,
versionItem: null
}
// Export/Import menu items
app.locals.enableDropBoxSave = config.isDropboxEnable
app.locals.enableGitHubGist = config.isGitHubEnable
app.locals.enableGitlabSnippets = config.isGitlabSnippetsEnable
app.use(require('./dist/routes').router)
// response not found if no any route matxches
app.get('*', function (req, res) {
response.errorNotFound(req, 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 () {
var address
var listenCallback = function () {
var schema = config.useSSL ? 'HTTPS' : 'HTTP'
logger.info('%s Server listening at %s', schema, address)
realtime.maintenance = false
}
// use unix domain socket if 'path' is specified
if (config.path) {
address = config.path
server.listen(config.path, listenCallback)
} else {
address = config.host + ':' + config.port
server.listen(config.port, config.host, listenCallback)
}
}
// 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')
}
}).catch(err => {
logger.error('Can\'t sync database')
logger.error(err.stack)
logger.error('Process will exit now.')
process.exit(1)
})
// log uncaught exception
process.on('uncaughtException', function (err) {
logger.error('An uncaught exception has occured.')
logger.error(err)
console.error(err)
logger.error('Process will exit now.')
process.exit(1)
})
// install exit handler
function handleTermSignals () {
logger.info('CodiMD has been killed by signal, try to exit gracefully...')
realtime.maintenance = true
realtime.terminate()
// 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)
setTimeout(() => {
process.exit(1)
}, 5000)
}
process.on('SIGINT', handleTermSignals)
process.on('SIGTERM', handleTermSignals)
process.on('SIGQUIT', handleTermSignals)
require('./dist/app.js')

343
lib/app.ts Normal file
View File

@ -0,0 +1,343 @@
// app
// external modules
import * as express from "express";
import * as ejs from "ejs";
import * as passport from "passport";
import * as methodOverride from "method-override";
import * as cookieParser from "cookie-parser";
import * as session from "express-session";
import * as fs from "fs";
import * as path from "path";
import * as morgan from "morgan";
import * as helmet from "helmet";
import * as i18n from "i18n";
import * as flash from "connect-flash";
import {expressMiddleware as expressPrometheusMetricsMiddleware} from "prometheus-api-metrics";
import * as connectSessionSequelize from 'connect-session-sequelize'
import * as passportSocketIo from 'passport.socketio'
import * as SocketIo from 'socket.io'
import {Server as WsServer} from 'ws'
// core
import * as config from "./config";
import * as logger from "./logger";
import * as response from "./response";
import * as models from "./models";
import * as csp from "./csp";
import {Environment} from "./config/enum";
import {checkVersion, versionCheckMiddleware} from "./web/middleware/checkVersion";
import TooBusyMiddleware from './middleware/tooBusy'
import CheckURIValidMiddleware from './middleware/checkURIValid'
import RedirectWithoutTrailingSlashesMiddleware from './middleware/redirectWithoutTrailingSlashes'
import CodiMDVersionMiddleware from './middleware/codiMDVersion'
import {router as MetricsRouter} from './metrics'
import {router as MainRouter} from './routes'
import * as realtime from './realtime/realtime'
const SequelizeStore = connectSessionSequelize(session.Store)
function createHttpServer() {
if (config.useSSL) {
const ca = (function () {
let i, len
const results = []
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
}
return results
})()
const 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
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('https').createServer(options, app)
} else {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('http').createServer(app)
}
}
// server setup
const app = express()
const server = createHttpServer()
// API and process monitoring with Prometheus for Node.js micro-service
app.use(expressPrometheusMetricsMiddleware({
metricsPath: '/metrics/router',
excludeRoutes: ['/metrics/codimd']
}))
// logger
app.use(morgan('combined', {
stream: logger.stream
}))
// socket io
const io = SocketIo(server)
io.engine.ws = new WsServer({
noServer: true,
perMessageDeflate: false
})
// others
// assign socket io to realtime
realtime.setSocketIo(io)
// methodOverride
app.use(methodOverride('_method'))
// session store
const sessionStore = new SequelizeStore({
db: models.sequelize
})
// use hsts to tell https users stick to this
if (config.hsts.enable) {
app.use(helmet.hsts({
maxAge: config.hsts.maxAgeSeconds,
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')
}
// Add referrer policy to improve privacy
app.use(
helmet.referrerPolicy({
policy: 'same-origin'
})
)
// Generate a random nonce per request, for CSP with inline scripts
app.use(csp.addNonceToLocals)
// use Content-Security-Policy to limit XSS, dangerous plugins, etc.
// https://helmetjs.github.io/docs/csp/
if (config.csp.enable) {
app.use(helmet.contentSecurityPolicy({
directives: csp.computeDirectives()
}))
} else {
logger.info('Content-Security-Policy is disabled. This may be a security risk.')
}
i18n.configure({
locales: ['en', 'zh-CN', 'zh-TW', 'fr', 'de', 'ja', 'es', 'ca', 'el', 'pt', 'it', 'tr', 'ru', 'nl', 'hr', 'pl', 'uk', 'hi', 'sv', 'eo', 'da', 'ko', 'id', 'sr'],
cookie: 'locale',
directory: path.join(__dirname, '/locales'),
updateFiles: config.updateI18nFiles
})
app.use(cookieParser())
app.use(i18n.init)
// routes without sessions
// static files
app.use('/', express.static(path.join(__dirname, '/public'), {maxAge: config.staticCacheTime, index: false}))
app.use('/docs', express.static(path.resolve(__dirname, config.docsPath), {maxAge: config.staticCacheTime}))
app.use('/uploads', express.static(path.resolve(__dirname, config.uploadsPath), {maxAge: config.staticCacheTime}))
app.use('/default.md', express.static(path.resolve(__dirname, config.defaultNotePath), {maxAge: config.staticCacheTime}))
app.use(MetricsRouter)
// 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
const tlsSessionStore = {}
server.on('newSession', function (id: Buffer, data, cb) {
tlsSessionStore[id.toString('hex')] = data
cb()
})
server.on('resumeSession', function (id: Buffer, cb) {
cb(null, tlsSessionStore[id.toString('hex')] || null)
})
// middleware which blocks requests when we're too busy
app.use(TooBusyMiddleware)
app.use(flash())
// passport
app.use(passport.initialize())
app.use(passport.session())
// check uri is valid before going further
app.use(CheckURIValidMiddleware)
// redirect url without trailing slashes
app.use(RedirectWithoutTrailingSlashesMiddleware)
app.use(CodiMDVersionMiddleware)
if (config.autoVersionCheck && process.env.NODE_ENV === Environment.production) {
checkVersion(app)
app.use(versionCheckMiddleware)
}
// routes need sessions
// template files
app.set('views', config.viewPath)
// set render engine
app.engine('ejs', ejs.renderFile)
// set view engine
app.set('view engine', 'ejs')
// set generally available variables for all views
app.locals.useCDN = config.useCDN
app.locals.serverURL = config.serverURL
app.locals.sourceURL = config.sourceURL
app.locals.privacyPolicyURL = config.privacyPolicyURL
app.locals.allowAnonymous = config.allowAnonymous
app.locals.allowAnonymousEdits = config.allowAnonymousEdits
app.locals.permission = config.permission
app.locals.allowPDFExport = config.allowPDFExport
app.locals.authProviders = {
facebook: config.isFacebookEnable,
twitter: config.isTwitterEnable,
github: config.isGitHubEnable,
bitbucket: config.isBitbucketEnable,
gitlab: config.isGitLabEnable,
mattermost: config.isMattermostEnable,
dropbox: config.isDropboxEnable,
google: config.isGoogleEnable,
ldap: config.isLDAPEnable,
ldapProviderName: config.ldap.providerName,
saml: config.isSAMLEnable,
oauth2: config.isOAuth2Enable,
oauth2ProviderName: config.oauth2.providerName,
openID: config.isOpenIDEnable,
email: config.isEmailEnable,
allowEmailRegister: config.allowEmailRegister
}
app.locals.versionInfo = {
latest: true,
versionItem: null
}
// Export/Import menu items
app.locals.enableDropBoxSave = config.isDropboxEnable
app.locals.enableGitHubGist = config.isGitHubEnable
app.locals.enableGitlabSnippets = config.isGitlabSnippetsEnable
app.use(MainRouter)
// response not found if no any route matxches
app.get('*', function (req, res) {
response.errorNotFound(req, 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() {
let address
const listenCallback = function () {
const schema = config.useSSL ? 'HTTPS' : 'HTTP'
logger.info('%s Server listening at %s', schema, address)
realtime.setMaintenance(false)
}
// use unix domain socket if 'path' is specified
if (config.path) {
address = config.path
server.listen(config.path, listenCallback)
} else {
address = config.host + ':' + config.port
server.listen(config.port, config.host, listenCallback)
}
}
// 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')
}
}).catch(err => {
logger.error('Can\'t sync database')
logger.error(err.stack)
logger.error('Process will exit now.')
process.exit(1)
})
// log uncaught exception
process.on('uncaughtException', function (err) {
logger.error('An uncaught exception has occured.')
logger.error(err)
console.error(err)
logger.error('Process will exit now.')
process.exit(1)
})
// install exit handler
function handleTermSignals() {
logger.info('CodiMD has been killed by signal, try to exit gracefully...')
realtime.setMaintenance(true)
realtime.terminate()
// disconnect all socket.io clients
Object.keys(io.sockets.sockets).forEach(function (key) {
const socket = io.sockets.sockets[key]
// notify client server going into maintenance status
socket.emit('maintenance')
setTimeout(function () {
socket.disconnect(true)
}, 0)
})
const 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)
setTimeout(() => {
process.exit(1)
}, 5000)
}
process.on('SIGINT', handleTermSignals)
process.on('SIGTERM', handleTermSignals)
process.on('SIGQUIT', handleTermSignals)

View File

@ -10,4 +10,4 @@ const appRouter = Router()
appRouter.get('/status', wrap(statusController.getStatus))
appRouter.get('/metrics/codimd', wrap(statusController.getMetrics))
exports.router = appRouter
export const router = appRouter

View File

@ -1,7 +1,7 @@
import * as logger from '../logger'
import * as response from "../response";
export = function (req, res, next) {
export default function (req, res, next) {
try {
decodeURIComponent(req.path)
} catch (err) {

View File

@ -1,7 +1,7 @@
import * as config from "../config";
import {Request, Response} from "express";
export = function (req: Request, res: Response, next) {
export default function (req: Request, res: Response, next) {
res.set({
'CodiMD-Version': config.version
})

View File

@ -2,7 +2,7 @@ import {Request, Response} from "express";
import * as config from "../config";
export = function (req: Request, res: Response, next) {
export default function (req: Request, res: Response, next) {
if (req.method === 'GET' && req.path.substr(-1) === '/' && req.path.length > 1) {
const queryString = req.url.slice(req.path.length)
const urlPath = req.path.slice(0, -1)

View File

@ -5,7 +5,7 @@ import * as response from "../response";
toobusy.maxLag(config.responseMaxLag)
export = function (req, res, next) {
export default function (req, res, next) {
if (toobusy()) {
response.errorServiceUnavailable(req, res)
} else {

View File

@ -25,8 +25,16 @@ import {SaveRevisionJob} from "./realtimeSaveRevisionJob";
const chance = new Chance()
export const io = null
export const maintenance = true
export let io = null
export let maintenance = true
export function setSocketIo(socketIO) {
io = socketIO
}
export function setMaintenance(isMaintenance) {
maintenance = isMaintenance
}
// public
const connectProcessQueue = new ProcessQueue({})

View File

@ -85,4 +85,4 @@ appRouter.get('/:noteId/:action', noteController.noteActions)
// note actions with action id
appRouter.get('/:noteId/:action/:actionId', noteController.noteActions)
exports.router = appRouter
export const router = appRouter

View File

@ -18,7 +18,7 @@
"build": "webpack --config webpack.prod.js --display errors-only -p",
"dev": "webpack --config webpack.dev.js --progress --colors --watch",
"doctoc": "doctoc --title='# Table of Contents' README.md",
"lint": "standard && eslint lib/**",
"lint": "standard ./public && eslint --quiet lib/**/*.ts lib/app.ts",
"jsonlint": "find . -type f -not -ipath \"./node_modules/*\" -not -ipath \"./.vscode/*\" \\( -name \"*.json\" -o -name \"*.json.*\" \\) | xargs -n 1 -I{} -- bash -c 'echo {}; jq . {} > /dev/null;'",
"start": "sequelize db:migrate && node app.js",
"mocha": "mocha --require intelli-espower-loader --exit ./test --recursive",