citizen
Version:
Node.js MVC web application framework. Includes routing, serving, caching, session management, and other helpful tools.
1,159 lines (1,001 loc) • 75 kB
JavaScript
// server
// node
import events from 'node:events'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import http from 'node:http'
import https from 'node:https'
import querystring from 'node:querystring'
import { StringDecoder } from 'node:string_decoder'
import util from 'node:util'
import zlib from 'node:zlib'
// citizen
import cache from './cache.js'
import helpers from './helpers.js'
import router from './router.js'
import session from './session.js'
// event hooks
import applicationHooks from './hooks/application.js'
import requestHooks from './hooks/request.js'
import responseHooks from './hooks/response.js'
import sessionHooks from './hooks/session.js'
const
// Promisify zlib methods
compress = {
deflate : util.promisify(zlib.deflate),
gzip : util.promisify(zlib.gzip)
},
// server events
server = new events.EventEmitter({ captureRejections: true })
// Global promise unhandledRejection handler
process.on('unhandledRejection', (reason) => {
helpers.log({
type : 'error:server',
label : 'Unhandled promise rejection',
content : reason
})
})
// Server event handlers
server.on('applicationStart', async (options) => {
CTZN.config = helpers.extend(CTZN.config, options)
global[CTZN.config.citizen.global].config = helpers.copy(CTZN.config)
await applicationHooks.start()
createServer()
})
server.on('requestStart', async (params, request, response) => {
let context = {}
// Set the session parameters if sessions are enabled and a session exists
if ( CTZN.config.citizen.sessions.enabled && params.cookie.ctzn_session_id ) {
params.session = CTZN.sessions[params.cookie.ctzn_session_id]?.app || params.session
}
try {
context = helpers.extend(context, await requestHooks.start(params, request, response, context))
// Set headers based on controller action headers
if ( context.header ) {
context.headerLowercase = {}
Object.keys(context.header).forEach( item => {
response.setHeader(item, context.header[item])
// Create lowercase version of header name for easier comparison later
context.headerLowercase[item.toLowerCase()] = context.header[item]
})
delete context.header
}
server.emit('request', params, request, response, context)
} catch (err) {
server.emit('error', err, params, request, response, context)
}
})
server.on('request', function (params, request, response, context) {
// If a previous event in the request context requested a redirect, do it immediately.
if ( context.redirect && ( typeof context.redirect === 'string' || Object.keys(context.redirect).length ) && !params.route.direct ) {
redirect(params, request, response, context)
} else {
// If the route controller action exists, extend the global config with the controller config
if ( CTZN.controllers.routes[params.route.controller]?.[params.route.action] ) {
extendConfig(params, params.route.controller, params.route.action)
let corsSession = false,
respond = true,
originRegex = new RegExp('^' + params.route.base + '$')
// If the Origin header exists and it's not the host, check if it's allowed. If not,
// throw an appropriate error.
if ( request.headers.origin && !originRegex.test(request.headers.origin) ) {
const failed = (statusCode) => {
respond = false
let err = new Error()
err.statusCode = statusCode
server.emit('error', err, params, request, response, context)
}
request.cors = true
if ( params.config.citizen.cors ) {
// Create lowercase version of headers for easier comparison, while maintaining case for sent header
params.config.citizen.corsLowerCase = {}
Object.keys(params.config.citizen.cors).forEach( item => {
params.config.citizen.corsLowerCase[item.toLowerCase()] = params.config.citizen.cors[item]
})
let allowOriginRegex = new RegExp('^' + request.headers.origin + '$'),
allowMethodsRegex = new RegExp(request.method)
// If the request origin matches the CORS allowed origin, proceed.
if ( params.config.citizen.corsLowerCase['access-control-allow-origin'] && ( params.config.citizen.corsLowerCase['access-control-allow-origin'] === '*' ) || allowOriginRegex.test(params.config.citizen.corsLowerCase['access-control-allow-origin']) ) {
// If the request method isn't allowed, respond with a 405
if ( allowMethodsRegex.test(params.config.citizen.corsLowerCase['access-control-allow-methods']) ) {
// Respond with the headers from the controller. The processRequest() function
// will determine whether to end the request in the case of an OPTIONS method
// (preflight request) or fire the controller.
Object.keys(params.config.citizen.cors).forEach( item => {
response.setHeader(item, params.config.citizen.cors[item])
})
// Only create a session if Access-Control-Allow-Credentials is set and it's
// not a preflight request.
if ( request.method !== 'OPTIONS' && params.config.citizen.corsLowerCase['access-control-allow-credentials'] ) {
corsSession = true
}
} else {
failed(405)
}
} else {
failed(403)
}
} else {
failed(403)
}
}
if ( respond ) {
if ( params.config.citizen.sessions.enabled && ( !request.cors || ( request.cors && corsSession ) ) ) {
server.emit('sessionStart', params, request, response, context)
} else {
processRequest(params, request, response, context)
}
}
// If the controller/action pairing doesn't exist, throw a 404
} else {
let err = new Error()
err.statusCode = 404
server.emit('error', err, params, request, response, {})
}
}
})
server.on('sessionStart', async (params, request, response, context) => {
if ( params.cookie.ctzn_session_id && CTZN.sessions[params.cookie.ctzn_session_id] && CTZN.sessions[params.cookie.ctzn_session_id].properties.expires > Date.now() ) {
session.extend(params.cookie.ctzn_session_id)
params.session = CTZN.sessions[params.cookie.ctzn_session_id].app
try {
setSession(params, request, response, context)
processRequest(params, request, response, context)
} catch (err) {
server.emit('error', err, params, request, response, context)
}
} else {
let sessionID = session.create(request)
context.cookie = context.cookie || {}
context.cookie.ctzn_session_id = sessionID
params.session = CTZN.sessions[sessionID].app
try {
context = helpers.extend(context, await sessionHooks.start(params, request, response, context))
setSession(params, request, response, context)
processRequest(params, request, response, context)
} catch (err) {
server.emit('error', err, params, request, response, context)
}
}
})
server.on('requestEnd', async (params, request, response, context) => {
try {
context = helpers.extend(context, await requestHooks.end(params, request, response, context))
setSession(params, request, response, context)
server.emit('responseStart', params, request, response, context)
} catch (err) {
server.emit('error', err, params, request, response, context)
}
})
server.on('responseStart', async (params, request, response, context) => {
try {
context = helpers.extend(context, await responseHooks.start(params, request, response, context))
serverResponse(params, request, response, context)
} catch (err) {
server.emit('error', err, params, request, response, context)
}
})
server.on('responseEnd', async (params, request, response, context) => {
try {
context = helpers.extend(context, await responseHooks.end(params, request, response, context))
} catch (err) {
server.emit('error', err, params, request, response, context)
}
})
server.on('error', async (err, params, request, response, context, respond = true) => {
let statusCode = err.statusCode || 500,
label = err.label || statusCode + ' ' + http.STATUS_CODES[statusCode],
logContent = err.message || false,
result = err.result || params.config.citizen.errors,
type = statusCode >= 500 ? 'error:server' : 'error:client'
if ( statusCode >= 500 ) {
logContent = err.stack ? err.stack : util.inspect(err)
} else if ( statusCode === 404 && request.headers.referer ) {
logContent = '(Referrer: ' + request.headers.referer + ')'
}
helpers.log({
type : type,
label : helpers.serverLogLabel(statusCode, params, request),
content : logContent
})
// Run the application error event hook for 500-level errors
if ( statusCode >= 500 ) {
try {
await applicationHooks.error(err, params, request, response, context)
} catch ( err ) {
helpers.log({
type : 'error:server',
label : 'Application error handler failed',
content : err.stack ? err.stack : util.inspect(err)
})
}
}
if ( params && !response.writableEnded ) {
response.statusCode = statusCode
// Default behavior on error is "capture". If set to "exit", allow the error to exit the process.
if ( result === 'exit' ) {
response.end()
process.exit(1)
}
if ( respond ) {
// Display the error view if it's a 404, or as long as the error didn't come from the error view itself.
if ( statusCode === 404 || !Object.keys(params.route.chain).length || params.route.chain[Object.keys(params.route.chain)[0]].controller !== 'error' ) {
// Wipe the context and controller chain
context = {
local: {
error: {
errorCode : err.code,
statusCode : statusCode,
label : label,
message : err.message,
raw : err,
stack : err.stack
}
}
}
params.route.controller = 'error'
params.route.action = ''
params.route.chain = {
error: {
controller: 'error',
action: '',
view: '',
params: helpers.copy(params),
context: helpers.copy(context)
}
}
if ( response.contentType === 'text/html' || response.contentType === 'text/plain' ) {
// Render an error view if it exists
if ( CTZN.views.error ) {
if ( CTZN.views.error[err.code] ) {
params.route.chain.error.view = err.code
} else if ( CTZN.views.error[statusCode] ) {
params.route.chain.error.view = statusCode
} else {
params.route.chain.error.view = 'error'
}
helpers.log({
label: 'Rendering error view',
content: {
view : params.route.chain.error.view
}
})
params.route.chain.error.output = await renderView(params, request, response, context, {
controller : params.route.chain.error.controller,
view : params.route.chain.error.view,
params : params.route.chain.error.params,
context : setPublicContext(params, request, response, context, params.route.chain.error.context, params.route.chain.error.params),
jsonNamespace : true
})
.then(async output => {
return output
})
.catch(err => {
// If the error view throws an error, return that error along with the original error.
return '<section><p><strong>Your app threw an error while trying to render the error view (+2 Irony bonus):</strong></p>' +
'<pre>' + err.stack + '</pre></section><br><br><br><br>' +
'<section><p><strong>The following error is what caused the application error handler to fire in the first place:</strong></p>' +
'<pre>' + params.route.chain.error.context.local.error.stack + '</pre></section>'
})
// Hand off the error to the layout controller if there is one, as long as the error didn't originate in the layout controller itself.
if ( params.config.citizen.layout.controller.length && CTZN.controllers.routes[params.config.citizen.layout.controller] && params.route.chain[Object.keys(params.route.chain)[Object.keys(params.route.chain).length-1]].controller !== params.config.citizen.layout.controller ) {
context.next = params.config.citizen.layout
}
// Render only the error
} else {
params.route.chain.error.output = '<pre>' + context.local.error.stack + '</pre>'
}
}
next(params, request, response, context)
// If the error view throws an error, return that error along with the original error.
} else {
let output = '<section><p><strong>Your app threw an error while trying to render the error view (+2 Irony bonus):</strong></p>' +
'<pre>' + err.stack + '</pre></section><br><br><br><br>' +
'<section><p><strong>The following error is what caused the application error handler to fire in the first place:</strong></p>' +
'<pre>' + params.route.chain.error.context.local.error.stack + '</pre></section>'
response.write(output)
response.end()
}
}
} else {
// Default behavior on error is "capture". If set to "exit", allow the error to exit the process.
if ( result === 'exit' ) {
response.end()
process.exit(1)
}
}
})
// Server functions
export const start = (options) => {
server.emit('applicationStart', options)
}
function createServer() {
let modeMessage = ''
switch ( CTZN.config.citizen.mode ) {
case 'development':
modeMessage += '\n' +
'\n citizen is in development mode, which enables verbose console logs and' +
'\n disables view template caching, degrading performance.' +
'\n' +
'\n To enable production mode, set your NODE_ENV variable to "production" or' +
'\n manually change the citizen config mode to "production".' +
'\n' +
'\n Consult the README for details.'
break
case 'production':
modeMessage += '\n' +
'\n citizen is in production mode, which disables console logs and enables view' +
'\n caching. Debug content can be logged to a file by setting logs.debug to true' +
'\n in your citizen config, but your log file size will explode.'
'\n' +
'\n To enable development mode, set your NODE_ENV variable to "development" or' +
'\n manually change the citizen mode setting to "development".' +
'\n' +
'\n Consult the README for details.'
break
}
if ( CTZN.config.citizen.http.enabled ) {
const httpServer = http.createServer(CTZN.config.citizen.http, (request, response) => {
serve(request, response, 'http')
})
let appUrl = 'http://' + ( CTZN.config.citizen.http.hostname.length ? CTZN.config.citizen.http.hostname : '127.0.0.1' ) + ( CTZN.config.citizen.http.port === 80 ? '' : ':' + CTZN.config.citizen.http.port )
httpServer.listen(CTZN.config.citizen.http.port, CTZN.config.citizen.http.hostname, CTZN.config.citizen.connectionQueue, function () {
let startupMessage = '\nHTTP server started\n' +
'\n Application mode: ' + CTZN.config.citizen.mode +
'\n Port: ' + CTZN.config.citizen.http.port +
'\n Local URL: ' + appUrl
if ( !CTZN.config.citizen.http.hostname.length ) {
startupMessage += '\n\n You\'ve specified an empty hostname, so the server will respond to requests at any host.'
}
startupMessage += modeMessage
helpers.log({
content: startupMessage,
console: true,
timestamp: false
})
})
httpServer.on('error', (err) => {
switch ( err.code ) {
case 'EACCES':
helpers.log({
content: '\nHTTP server startup failed because port ' + CTZN.config.citizen.http.port + ' isn\'t open. Please open this port or set an alternate port for HTTP traffic in your config file using the "citizen.http.port" setting.\n',
console: true,
timestamp: false
})
break
case 'EADDRINUSE':
helpers.log({
content: '\nHTTP server startup failed because port ' + CTZN.config.citizen.http.port + ' is already in use. Please set an alternate port for HTTP traffic in your config file using the "citizen.http.port" setting.\n',
console: true,
timestamp: false
})
break
case 'ENOTFOUND':
helpers.log({
content: '\nHTTP server startup failed because the hostname you specified in your config ("' + CTZN.config.citizen.http.hostname + '") wasn\'t found.\n\nTry running citizen without specifying a hostname (accessible via ' + appUrl + ' locally or your server\'s IP address remotely). If that works, then the issue is probably in your server\'s DNS settings.\n',
console: true,
timestamp: false
})
break
case 'EADDRNOTAVAIL':
helpers.log({
content: '\nHTTP server startup failed because the hostname you specified in your config file ("' + CTZN.config.citizen.http.hostname + '") is unavailable. Have you configured your environment for this hostname? Is there another web server running on this machine?\n',
console: true,
timestamp: false
})
break
default:
helpers.log({
content: '\nThere was a problem starting the server. The port and hostname you specified in your config file appear to be available, so please review your other settings and make sure everything is correct.\n\nError code: ' + err.code + '\n\ncitizen doesn\'t recognize this error code, so please submit a bug report containing this error code along with the contents of your config file to:\n\nhttps://github.com/jaysylvester/citizen/issues\n\nThe full error is below:\n\n' + err,
console: true,
timestamp: false
})
break
}
process.exit(1)
})
}
if ( CTZN.config.citizen.https.enabled ) {
let startHttps = true,
httpsOptions = helpers.copy(CTZN.config.citizen.https)
try {
if ( CTZN.config.citizen.https.pfx ) {
httpsOptions.pfx = fs.readFileSync(CTZN.config.citizen.https.pfx)
} else if ( CTZN.config.citizen.https.key && CTZN.config.citizen.https.cert ) {
httpsOptions.key = fs.readFileSync(CTZN.config.citizen.https.key)
httpsOptions.cert = fs.readFileSync(CTZN.config.citizen.https.cert)
} else {
throw new Error('HTTPS requires either a key/cert file pair or PFX file, and your config file has specified neither.')
}
} catch (err) {
startHttps = false
helpers.log({
label: 'HTTPS server startup failed because there was a problem trying to read your key/cert file(s).',
content: err
})
process.exit(1)
}
if ( startHttps ) {
const httpsServer = https.createServer(httpsOptions, (request, response) => {
serve(request, response, 'https')
})
let appUrl = 'https://' + ( CTZN.config.citizen.https.hostname.length ? CTZN.config.citizen.https.hostname : '127.0.0.1' ) + ( CTZN.config.citizen.http.port === 443 ? '' : ':' + CTZN.config.citizen.http.port )
httpsServer.listen(CTZN.config.citizen.https.port, CTZN.config.citizen.https.hostname, CTZN.config.citizen.connectionQueue, function () {
let startupMessage = '\nHTTPS server started\n' +
'\n Application mode: ' + CTZN.config.citizen.mode +
'\n Port: ' + CTZN.config.citizen.https.port +
'\n Local URL: ' + appUrl
if ( !CTZN.config.citizen.https.hostname.length ) {
startupMessage += '\n\n You\'ve specified an empty hostname, so the server will respond to requests at any host.'
}
startupMessage += modeMessage
helpers.log({
content: startupMessage,
console: true,
timestamp: false
})
})
httpsServer.on('error', (err) => {
switch ( err.code ) {
case 'EACCES':
helpers.log({
content: '\nHTTPS server startup failed because port ' + CTZN.config.citizen.https.port + ' isn\'t open. Please open this port or set an alternate port for HTTPS traffic in your config file using the "citizn.https.port" setting.\n',
console: true,
timestamp: false
})
break
case 'EADDRINUSE':
helpers.log({
content: '\nHTTPS server startup failed because port ' + CTZN.config.citizen.https.port + ' is already in use. Please set an alternate port for HTTPs traffic in your config file using the "citizen.https.port" setting.\n',
console: true,
timestamp: false
})
break
case 'ENOTFOUND':
helpers.log({
content: '\nHTTPS server startup failed because the hostname you specified in your config file ("' + CTZN.config.citizen.https.hostname + '") wasn\'t found.\n\nTry running citizen without specifying a hostname (accessible via ' + appUrl + ' locally or your server\'s IP address remotely). If that works, then the issue is probably in your server\'s DNS settings.\n',
console: true,
timestamp: false
})
break
case 'EADDRNOTAVAIL':
helpers.log({
content: '\nHTTPS server startup failed because the hostname you specified in your config file ("' + CTZN.config.citizen.https.hostname + '") is unavailable. Have you configured your environment for this hostname? Is there another web server running on this machine?\n',
console: true,
timestamp: false
})
break
case 'ENOENT':
helpers.log({
content: '\nHTTPS server startup failed because citizen couldn\'t find the PFX or key/cert files you specified.\n',
console: true,
timestamp: false
})
break
case 'NOPFXORKEYCERT':
helpers.log({
content: '\nHTTPS server startup failed because you didn\'t provide the necessary key files. You need to specify either a PFX file or key/cert pair.\n',
console: true,
timestamp: false
})
break
default:
helpers.log({
content: '\nThere was a problem starting the server. The port and hostname you specified in your config file appear to be available, so please review your other settings and make sure everything is correct.\n\nError code: ' + err.code + '\n\ncitizen doesn\'t recognize this error code, so please submit a bug report containing this error code along with the contents of your config file to:\n\nhttps://github.com/jaysylvester/citizen/issues\n\nThe full error is below:\n\n' + err,
console: true,
timestamp: false
})
break
}
process.exit(1)
})
}
}
}
async function serve(request, response, protocol) {
let forwardedHeader = request.headers.forwarded ? request.headers.forwarded.trim().split(';').filter(item => item) : false,
forwardedHeaderProperties = {}
if ( forwardedHeader ) {
for ( let i = 0; i < forwardedHeader.length; i++ ) {
forwardedHeader[i] = forwardedHeader[i].split('=')
forwardedHeaderProperties[forwardedHeader[i][0]] = forwardedHeader[i][1]
}
request.headers.forwardedParsed = forwardedHeaderProperties
}
let route = router.parseRoute(request, protocol),
params = {
config : helpers.copy(CTZN.config),
cookie : parseCookie(request.headers.cookie),
form : {},
payload : {},
route : route,
session : {},
url : route.urlParams
}
// Prevents further response execution when the client arbitrarily closes the connection, which
// helps conserve resources.
response.on('close', function () {
if ( !response.writableEnded ) {
response.end()
helpers.log({
label: 'Connection closed',
content: ' (Route: ' + params.route.url + ')'
})
}
})
request.remoteAddress = request.headers.forwardedParsed?.for || request.headers['x-forwarded-for'] || request.socket.remoteAddress
response.encoding = CTZN.config.citizen.compression.force || 'identity'
let compressable = CTZN.config.citizen.compression.enabled && !CTZN.config.citizen.compression.force && ( !params.route.isStatic || CTZN.config.citizen.compression.mimeTypes.indexOf(router.staticMimeTypes[params.route.extension]) >= 0 )
// Determine client encoding support for compressable assets. Can be forced via config.citizen.compression.force
if ( compressable ) {
let acceptEncoding = request.headers['accept-encoding'] ? request.headers['accept-encoding'].split(',') : [],
encoding = [],
weight = 0
for ( let i = 0; i < acceptEncoding.length; i++ ) {
acceptEncoding[i] = acceptEncoding[i].trim().split(';')
acceptEncoding[i][1] = acceptEncoding[i][1] ? +querystring.parse(acceptEncoding[i][1]).q : '1'
}
for ( let i = 0; i < acceptEncoding.length; i++ ) {
if ( acceptEncoding[i][1] > weight ) {
encoding.unshift([acceptEncoding[i][0], acceptEncoding[i][1]])
weight = acceptEncoding[i][1]
} else {
encoding.push([acceptEncoding[i][0], acceptEncoding[i][1]])
}
}
for ( let i = 0; i < encoding.length; i++ ) {
// Use the appropriate encoding if it's supported
if ( encoding[i][1] && ( encoding[i][0] === 'gzip' || encoding[i][0] === 'deflate' || encoding[i][0] === 'identity' ) ) {
response.encoding = encoding[i][0]
break
}
}
}
// If it's a dynamic page request, set the content type and check for the controller.
// Otherwise, serve the static asset.
if ( !params.route.isStatic ) {
// Determine preferred format requested by client
let acceptFormat = request.headers['accept']?.length ? request.headers['accept'].split(',') : helpers.copy(CTZN.config.citizen.contentTypes),
format = [],
weight = 0
for ( let i = 0; i < acceptFormat.length; i++ ) {
acceptFormat[i] = acceptFormat[i].trim().split(';')
acceptFormat[i][1] = acceptFormat[i][1] ? +querystring.parse(acceptFormat[i][1]).q : '1'
}
for ( let i = 0; i < acceptFormat.length; i++ ) {
if ( acceptFormat[i][1] > weight ) {
format.unshift([acceptFormat[i][0], acceptFormat[i][1]])
weight = acceptFormat[i][1]
} else {
format.push([acceptFormat[i][0], acceptFormat[i][1]])
}
}
for ( let i = 0; i < format.length; i++ ) {
// Choose the MIME type with the highest weight, or default to text/plain
if ( format[i][1] ) {
switch ( format[i][0] ) {
case 'text/html':
response.contentType = 'text/html'
response.setHeader('Content-Type', 'text/html')
break
case 'application/json':
response.contentType = 'application/json'
response.setHeader('Content-Type', 'application/json')
break
case 'application/javascript':
response.contentType = 'application/javascript'
response.setHeader('Content-Type', 'application/javascript')
break
default:
response.contentType = 'text/plain'
response.setHeader('Content-Type', 'text/plain')
break
}
break
}
}
// Emit the requestStart event
server.emit('requestStart', params, request, response)
} else {
let staticPath = CTZN.config.citizen.directories.web + params.route.filePath,
cachedFile = CTZN.config.citizen.cache.static.enabled ? cache.get({ file: staticPath, output: 'all' }) : false,
lastModified
response.setHeader('Content-Type', router.staticMimeTypes[params.route.extension])
response.setHeader('Cache-Control', 'max-age=0' )
if ( CTZN.config.citizen.cache.control[params.route.pathname] ) {
response.setHeader('Cache-Control', CTZN.config.citizen.cache.control[params.route.pathname] )
} else {
for ( var controlHeader in CTZN.config.citizen.cache.control ) {
if ( new RegExp(controlHeader).test(params.route.pathname) ) {
response.setHeader('Cache-Control', CTZN.config.citizen.cache.control[controlHeader] )
}
}
}
if ( cachedFile ) {
lastModified = cachedFile.stats.mtime.toISOString()
response.setHeader('ETag', lastModified)
if ( request.headers['if-none-match'] == lastModified ) {
response.setHeader('Date', lastModified)
response.statusCode = 304
response.end()
} else {
response.setHeader('Content-Encoding', response.encoding)
response.end(request.headers.method !== 'HEAD' ? cachedFile.value[response.encoding] : null)
}
helpers.log({
type: 'access',
label: helpers.serverLogLabel(response.statusCode, params, request)
})
} else {
fs.readFile(staticPath, function (err, data) {
if ( !err ) {
fs.stat(staticPath, async (err, stats) => {
lastModified = stats.mtime.toISOString()
if ( !response.headersSent ) {
response.setHeader('ETag', lastModified)
if ( request.headers['if-none-match'] == lastModified ) {
response.setHeader('Date', lastModified)
response.statusCode = 304
response.end()
} else {
if ( CTZN.config.citizen.compression.enabled && compressable ) {
let [
gzip,
deflate
] = await Promise.all([
CTZN.config.citizen.cache.static.enabled || response.encoding === 'gzip' ? compress.gzip(data) : false,
CTZN.config.citizen.cache.static.enabled || response.encoding === 'deflate' ? compress.deflate(data) : false
]).catch(err => { server.emit('error', err, params, request, response, {}) })
let compressed = {
gzip : gzip,
deflate : deflate
}
if ( !response.headersSent ) {
response.setHeader('Content-Encoding', response.encoding)
response.end(request.headers.method !== 'HEAD' ? compressed[response.encoding] : null)
}
if ( CTZN.config.citizen.cache.static.enabled ) {
try {
cache.set({
file: staticPath,
value: {
identity: data,
gzip: gzip,
deflate: deflate
},
stats: stats,
lifespan: CTZN.config.citizen.cache.static.lifespan,
resetOnAccess: CTZN.config.citizen.cache.static.resetOnAccess
})
} catch ( err ) {
server.emit('error', err, params, request, response, {})
}
}
} else {
response.setHeader('Content-Encoding', response.encoding)
response.end(request.headers.method !== 'HEAD' ? data : null)
if ( CTZN.config.citizen.cache.static.enabled ) {
try {
cache.set({
file: staticPath,
value: {
identity: data
},
stats: stats,
lifespan: CTZN.config.citizen.cache.static.lifespan,
resetOnAccess: CTZN.config.citizen.cache.static.resetOnAccess
})
} catch ( err ) {
server.emit('error', err, params, request, response, {})
}
}
}
}
helpers.log({
type: 'access',
label: helpers.serverLogLabel(response.statusCode, params, request)
})
}
})
} else {
response.statusCode = 404
response.end()
helpers.log({
type: 'error:client',
label: helpers.serverLogLabel(response.statusCode, params, request)
})
}
})
}
}
}
function serverResponse(params, request, response, context) {
let routeCache = cache.getRoute({
route: params.route.base + params.route.pathname,
contentType: response.contentType
})
setSession(params, request, response, context)
if ( routeCache ) {
setCookies(params, request, response, context)
// Set response headers based on cached context
if ( routeCache.context.header ) {
Object.keys(routeCache.context.header).forEach( item => {
response.setHeader(item, routeCache.context.header[item])
})
}
response.setHeader('ETag', routeCache.lastModified)
if ( request.headers['if-none-match'] == routeCache.lastModified ) {
response.setHeader('Date', routeCache.lastModified)
response.statusCode = 304
response.end()
} else {
response.setHeader('Content-Type', response.contentType)
response.setHeader('Content-Encoding', response.encoding)
response.end(request.headers.method !== 'HEAD' ? routeCache.encodings[response.encoding] : null)
}
server.emit('responseEnd', params, request, response, context)
} else {
fireController(params, request, response, context)
}
}
function setSession(params, request, response, context) {
if ( CTZN.config.citizen.sessions.enabled && CTZN.sessions[params.session.ctzn_session_id] && context.session && Object.keys(context.session).length ) {
if ( context.session.expires === 'now' ) {
session.end(params.session.ctzn_session_id)
context.cookie = helpers.extend(context.cookie, { ctzn_session_id: { expires: 'now' }})
params.session = {}
} else {
for ( let item in context.session ) {
CTZN.sessions[params.session.ctzn_session_id].app[item] = context.session[item]
}
params.session = CTZN.sessions[params.session.ctzn_session_id].app
}
}
delete context.session
}
function setCookies(params, request, response, context) {
if ( context.cookie ) {
let cookie = buildCookie(params, request, context.cookie)
if ( cookie.length ) {
response.setHeader('Set-Cookie', cookie)
}
}
}
async function processRequest(params, request, response, context) {
let payload = '',
requestContentType = request.headers['content-type'] || ''
const maxPayloadExceeded = (clientError = true) => {
let err = new Error('The payload size for this request exceeded the maximum limit specified in the server configuration.')
err.statusCode = 413
server.emit('error', err, params, request, response, context, clientError)
}
switch ( request.method ) {
case 'OPTIONS':
response.end()
server.emit('responseEnd', params, request, response, context)
break
case 'GET':
case 'HEAD': // nice
server.emit('requestEnd', params, request, response, context)
break
// Requests that can have payloads
case 'DELETE':
case 'PATCH':
case 'POST':
case 'PUT':
if ( requestContentType.startsWith('multipart/form-data') ) {
requestContentType = 'multipart/form-data'
}
if ( params.config.citizen.forms.enabled ) {
if ( request.headers['content-length'] < params.config.citizen.forms.maxPayloadSize ) {
// Concatenate chunks to build the full payload. Decode as binary to preserve
// images in multipart form data.
let stringDecoder = new StringDecoder('binary')
request.on('data', (chunk) => {
let decodedChunk = stringDecoder.write(chunk)
if ( payload.length + decodedChunk.length < params.config.citizen.forms.maxPayloadSize ) {
payload += decodedChunk
} else {
// This will close the connection without sending an error to the client,
// but if the Content-Length header was wrong and the payload is larger
// than promised, it's the client's fault. Assume malicious intent.
request.destroy()
maxPayloadExceeded(false)
}
})
request.on('end', async () => {
if ( payload.length ) {
let boundary = '',
fieldContent = [],
fields = {}
// Store the raw payload for dev access if desired
request.payload = payload
try {
switch ( requestContentType ) {
case 'application/json':
params.payload = JSON.parse(payload)
break
case 'application/x-www-form-urlencoded':
params.payload = Object.assign({}, querystring.parse(payload))
params.form = helpers.copy(params.payload)
break
case 'multipart/form-data':
boundary = '--' + request.headers['content-type'].slice(30)
fieldContent = payload.split(boundary)
// The payload boundary results in garbage strings in the first and last indexes,
// so start with the second index and end with the next-to-last index.
for ( let field = 1; field < fieldContent.length - 1; field++ ) {
fieldContent[field].replace(/Content-Disposition: form-data; (.+)\r\n(Content-Type: (.+)\r\n)?\r\n([^]+)\r\n/, (match, $directives, $contentTypeExists, $contentType, $value) => {
let directives = querystring.parse($directives.replace(/; /g, '&')),
fieldName = directives.name.replace(/"/g, '')
// If Content-Type exists, it's a file.
if ( $contentTypeExists ) {
// ------boundary
// Content-Disposition: form-data; name="field_name"; filename="file.name"
// Content-Type: file/type
//
// <file contents>
// ------boundary
let fileName = directives.filename.replace(/"/g, '')
fields[fieldName] = fields[fieldName] || {}
fields[fieldName][fileName] = {
filename : fileName,
contentType : $contentType,
binary : $value
}
} else {
// ------boundary
// Content-Disposition: form-data; name="field_name"
//
// field contents
// ------boundary
fields[fieldName] = $value
}
})
}
params.form = fields
break
}
} catch ( err ) {
server.emit('error', err, params, request, response, context)
}
}
helpers.log({
label : 'Payload received and parsed (' + request.headers['content-type'] + ')',
content : params.payload
})
server.emit('requestEnd', params, request, response, context)
})
} else {
maxPayloadExceeded()
}
} else {
server.emit('requestEnd', params, request, response, context)
}
break
}
}
async function fireController(params, request, response, context) {
let controller = context.next?.controller || params.route.controller,
action = context.next?.action || params.route.action,
view = context.next?.view || context.view || params.route.controller,
controllerParams = helpers.copy(params)
if ( context.next?.params ) {
controllerParams.route = context.next.params.route
controllerParams.url = context.next.params.url
}
// Clear the previous controller's next directive
delete context.next
// Check the controller cache unless it's the layout controller, which can't be cached
params.route.chain[controller] = controller === CTZN.config.citizen.layout.controller ? false : cache.getRoute({
route : controllerParams.route.pathname,
contentType : response.contentType
})
// Fire the controller if it isn't cached
if ( !params.route.chain[controller] ) {
helpers.log({
label: 'Firing controller: ' + controller,
content: {
controller: controller,
action: action
}
})
try {
params.route.chain[controller] = {
controller : controller,
action : action,
context : await CTZN.controllers.routes[controller][action](controllerParams, request, response, context) || {}
}
} catch (err) {
server.emit('error', err, params, request, response, context)
return err
}
// Extend the chain's context with the controller's returned context
context = helpers.extend(context, params.route.chain[controller].context)
// Preserve request cache directives from application/request/response hooks if it's the first controller in the chain
if ( Object.keys(params.route.chain).length === 1 && context.cache?.request ) {
params.route.chain[controller].context.cache = params.route.chain[controller].context.cache || {}
params.route.chain[controller].context.cache.request = context.cache.request
}
// Delete the cache directive for future controllers in the chain
delete context.cache
// Append the parameters passed to the controller to the controller object so they can be referenced later.
params.route.chain[controller].params = controllerParams
// Set default local context to avoid server errors during rendering and provide an empty namespaced object in JSON responses
params.route.chain[controller].context.local = params.route.chain[controller].context.local || {}
// Set the controller's view to that specified in the controller's context if it exists.
params.route.chain[controller].view = params.route.chain[controller].context.view || view
if ( !response.writableEnded ) {
// Set headers based on controller action headers
if ( context.header ) {
context.headerLowercase = context.headerLowercase || {}
Object.keys(context.header).forEach( item => {
response.setHeader(item, context.header[item])
// Create lowercase version of header name for easier comparison later
context.headerLowercase[item.toLowerCase()] = context.header[item]
})
}
let lastModified = context.cache?.lastModified ? context.cache.lastModified : new Date().toISOString()
response.setHeader('Cache-Control', context.headerLowercase?.['cache-control'] ? context.headerLowercase['cache-control'] : 'max-age=0')
response.setHeader('ETag', context.headerLowercase?.['etag'] ? context.headerLowercase['etag'] : lastModified)
delete context.header
delete context.headerLowercase
if ( params.config.citizen.contentTypes.indexOf(response.contentType) >= 0 ) {
setSession(params, request, response, context)
// Server-side redirect
if ( context.redirect && ( typeof context.redirect === 'string' || ( Object.keys(context.redirect).length && typeof context.redirect.refresh === 'undefined' ) ) && !params.route.direct ) {
redirect(params, request, response, context, true)
} else {
// Client-side redirect (the response will be sent to the client as normal)
if ( context.redirect && ( typeof context.redirect === 'string' || Object.keys(context.redirect).length ) && !params.route.direct ) {
redirect(params, request, response, context, true)
}
let include = params.route.chain[controller].context.include || {},
includeProperties = Object.getOwnPropertyNames(include),
includes = []
if ( includeProperties.length ) {
params.route.chain[controller].include = {}
includeProperties.forEach( function (item, index) {
// Create the functions to be fired in parallel
includes[index] = ( async () => {
// If the include directive is a string, assume it's a route and try to parse it
if ( typeof include[item] === 'string' ) {
let pathname = include[item]
try {
include[item] = {
params: helpers.copy(params)
}
include[item].params.route = router.parseRoute(request, include[item].params.route.protocol, pathname)
include[item].params.url = helpers.extend(params.route.urlParams, include[item].params.route.urlParams)
include[item].controller = include[item].params.route.controller
include[item].action = include[item].params.route.action
include[item].view = include[item].params.route.controller
} catch (err) {
err.message = 'The requested include route (' + pathname + ') is invalid. When using the route syntax to specify an include controller, the route must be a valid pathname starting with a forward slash (/).'
return Promise.reject(err)
}
} else {
include[item].params = helpers.copy(params)
include[item].action = include[item].action || 'handler'
include[item].view = include[item].view || include[item].controller
include[item].params.route = router.parseRoute(request, include[item].params.route.protocol, '/' + include[item].controller + ( include[item].action !== 'handler' ? '/action/' + include[item].action : '' ))
include[item].params.url = helpers.extend(params.route.urlParams, include[item].params.route.urlParams)
}
// Throw an error if the requested include controller doesn't exist.
if ( !CTZN.controllers.routes[include[item].controller] ) {
let err = new Error()
err.message = 'The requested include controller (' + include[item].controller + ', referenced within controller: ' + controller + ') doesn\'t exist.'
return Promise.reject(err)
}
// Reset the config in case it's been extended by the calling controller config
include[item].params.config.citizen = helpers.copy(CTZN.config.citizen)
// Extend the global config with the include controller config
extendConfig(include[item].params, include[item].controller, include[item].action)
params.route.chain[controller].include[item] = cache.getRoute({
route : include[item].params.route.pathname,
contentType : response.contentType
})
if ( params.route.chain[controller].include[item] ) {
helpers.log({
label: 'Using cached include controller action: ' + item,
content: {