codecombat/server_setup.coffee
Phoenix Eliot bdabee865c Filter domains for webdev iFrame
This serves the web-dev surface iFrame from another domain, such that user-created levels can't sniff cookies from a visitor to their page. It forces a redirect if a path is accesses through the wrong domain.

Use ENV variables for hostnames

Allow messages from all relevant domains

Use the right iFrame URL for different domains

Let the load balancer check /healthcheck

Add special handling for china server

Generalize subdomain handling
2016-09-06 16:17:38 -07:00

294 lines
12 KiB
CoffeeScript

express = require 'express'
path = require 'path'
authentication = require 'passport'
useragent = require 'express-useragent'
fs = require 'graceful-fs'
log = require 'winston'
compressible = require 'compressible'
geoip = require 'geoip-lite'
database = require './server/commons/database'
perfmon = require './server/commons/perfmon'
baseRoute = require './server/routes/base'
user = require './server/handlers/user_handler'
logging = require './server/commons/logging'
config = require './server_config'
auth = require './server/commons/auth'
routes = require './server/routes'
UserHandler = require './server/handlers/user_handler'
slack = require './server/slack'
Mandate = require './server/models/Mandate'
global.tv4 = require 'tv4' # required for TreemaUtils to work
global.jsondiffpatch = require 'jsondiffpatch'
global.stripe = require('stripe')(config.stripe.secretKey)
errors = require './server/commons/errors'
request = require 'request'
Promise = require 'bluebird'
Promise.promisifyAll(request, {multiArgs: true})
productionLogging = (tokens, req, res) ->
status = res.statusCode
color = 32
if status >= 500 then color = 31
else if status >= 400 then color = 33
else if status >= 300 then color = 36
elapsed = (new Date()) - req._startTime
elapsedColor = if elapsed < 500 then 90 else 31
return null if status is 404 and /\/feedback/.test req.originalUrl # We know that these usually 404 by design (bad design?)
if (status isnt 200 and status isnt 201 and status isnt 204 and status isnt 304 and status isnt 302) or elapsed > 500
return "\x1b[90m#{req.method} #{req.originalUrl} \x1b[#{color}m#{res.statusCode} \x1b[#{elapsedColor}m#{elapsed}ms\x1b[0m"
null
developmentLogging = (tokens, req, res) ->
status = res.statusCode
color = 32
if status >= 500 then color = 31
else if status >= 400 then color = 33
else if status >= 300 then color = 36
elapsed = (new Date()) - req._startTime
elapsedColor = if elapsed < 500 then 90 else 31
s = "\x1b[90m#{req.method} #{req.originalUrl} \x1b[#{color}m#{res.statusCode} \x1b[#{elapsedColor}m#{elapsed}ms\x1b[0m"
s += ' (proxied)' if req.proxied
return s
setupDomainFilterMiddleware = (app) ->
if config.isProduction
unsafePaths = [
/^\/web-dev-iframe\.html$/
/^\/javascripts\/web-dev-listener\.js$/
]
serveFromBoth = [
/^\/healthcheck$/ # Allow the load balancer to check if we're up yet
/^\/javascripts\/workers\/aether_worker\.js$/
/^\/javascripts\/app\/vendor\/aether-html\.js$/
/^\/file\/db\/thang.type\/[a-f0-9]+\/.*$/
/^\/images\/.*$/
]
app.use (req, res, next) ->
domainRegex = new RegExp("(.*\.)?(#{config.mainHostname}|#{config.unsafeContentHostname})")
domainPrefix = req.host.match(domainRegex)?[1] or ''
if _.any(serveFromBoth, (pathRegex) -> pathRegex.test(req.path))
next()
else if _.any(unsafePaths, (pathRegex) -> pathRegex.test(req.path))
if req.host isnt domainPrefix + config.unsafeContentHostname
res.redirect('http://' + domainPrefix + config.unsafeContentHostname + req.path)
else
next()
else
next()
setupErrorMiddleware = (app) ->
app.use (err, req, res, next) ->
if err
if err.name is 'MongoError' and err.code is 11000
err = new errors.Conflict('MongoDB conflict error.')
if err.code is 422 and err.response
err = new errors.UnprocessableEntity(err.response)
if err.code is 409 and err.response
err = new errors.Conflict(err.response)
# TODO: Make all errors use this
if err instanceof errors.NetworkError
return res.status(err.code).send(err.toJSON())
if err.status and 400 <= err.status < 500
res.status(err.status).send("Error #{err.status}")
return
res.status(err.status ? 500).send(error: "Something went wrong!")
message = "Express error: #{req.method} #{req.path}: #{err.message}"
log.error "#{message}, stack: #{err.stack}"
if global.testing
console.log "#{message}, stack: #{err.stack}"
slack.sendSlackMessage(message, ['ops'], {papertrail: true})
else
next(err)
setupExpressMiddleware = (app) ->
if config.isProduction
express.logger.format('prod', productionLogging)
app.use(express.logger('prod'))
app.use express.compress filter: (req, res) ->
return false if req.headers.host is 'codecombat.com' # CloudFlare will gzip it for us on codecombat.com
compressible res.getHeader('Content-Type')
else if not global.testing
express.logger.format('dev', developmentLogging)
app.use(express.logger('dev'))
app.use(express.static(path.join(__dirname, 'public'), maxAge: 0)) # CloudFlare overrides maxAge, and we don't want local development caching.
setupProxyMiddleware app # TODO: Flatten setup into one function. This doesn't fit its function name.
app.use(express.favicon())
app.use(express.cookieParser())
app.use(express.bodyParser())
app.use(express.methodOverride())
app.use(express.cookieSession({
key:'codecombat.sess'
secret:config.cookie_secret
}))
setupPassportMiddleware = (app) ->
app.use(authentication.initialize())
if config.picoCTF
app.use authentication.authenticate('local', failureRedirect: config.picoCTF_login_URL)
require('./server/lib/picoctf').init app
else
app.use(authentication.session())
auth.setup()
setupCountryRedirectMiddleware = (app, country="china", countryCode="CN", languageCode="zh", host="cn.codecombat.com") ->
shouldRedirectToCountryServer = (req) ->
speaksLanguage = _.any req.acceptedLanguages, (language) -> language.indexOf languageCode isnt -1
#Work around express 3.0
reqHost = req.hostname
reqHost ?= req.host
unless reqHost.toLowerCase() is host
ip = req.headers['x-forwarded-for'] or req.connection.remoteAddress
ip = ip?.split(/,? /)[0] # If there are two IP addresses, say because of CloudFlare, we just take the first.
geo = geoip.lookup(ip)
#if speaksLanguage or geo?.country is countryCode
# log.info("Should we redirect to #{serverID} server? speaksLanguage: #{speaksLanguage}, acceptedLanguages: #{req.acceptedLanguages}, ip: #{ip}, geo: #{geo} -- so redirecting? #{geo?.country is 'CN' and speaksLanguage}")
return geo?.country is countryCode and speaksLanguage
else
#log.info("We are on #{serverID} server. speaksLanguage: #{speaksLanguage}, acceptedLanguages: #{req.acceptedLanguages[0]}")
req.country = country if speaksLanguage
return false # If the user is already redirected, don't redirect them!
app.use (req, res, next) ->
if shouldRedirectToCountryServer req
res.writeHead 302, "Location": 'http://' + host + req.url
res.end()
else
next()
setupOneSecondDelayMiddleware = (app) ->
if(config.slow_down)
app.use((req, res, next) -> setTimeout((-> next()), 1000))
setupMiddlewareToSendOldBrowserWarningWhenPlayersViewLevelDirectly = (app) ->
isOldBrowser = (req) ->
# https://github.com/biggora/express-useragent/blob/master/lib/express-useragent.js
return false unless ua = req.useragent
return true if ua.isiPad or ua.isiPod or ua.isiPhone or ua.isOpera
return false unless ua and ua.Browser in ['Chrome', 'Safari', 'Firefox', 'IE'] and ua.Version
b = ua.Browser
try
v = parseInt ua.Version.split('.')[0], 10
catch TypeError
log.error('ua.Version does not have a split function.', JSON.stringify(ua, null, ' '))
return false
return true if b is 'Chrome' and v < 17
return true if b is 'Safari' and v < 6
return true if b is 'Firefox' and v < 21
return true if b is 'IE' and v < 11
false
app.use('/play/', useragent.express())
app.use '/play/', (req, res, next) ->
return next() if req.query['try-old-browser-anyway'] or not isOldBrowser req
res.sendfile(path.join(__dirname, 'public', 'index_old_browser.html'))
setupRedirectMiddleware = (app) ->
app.all '/account/profile/*', (req, res, next) ->
nameOrID = req.path.split('/')[3]
res.redirect 301, "/user/#{nameOrID}/profile"
setupPerfMonMiddleware = (app) ->
app.use perfmon.middleware
exports.setupMiddleware = (app) ->
setupPerfMonMiddleware app
setupCountryRedirectMiddleware app, "china", "CN", "zh", config.chinaDomain
setupCountryRedirectMiddleware app, "brazil", "BR", "pt-BR", config.brazilDomain
setupDomainFilterMiddleware app
setupMiddlewareToSendOldBrowserWarningWhenPlayersViewLevelDirectly app
setupExpressMiddleware app
setupPassportMiddleware app
setupOneSecondDelayMiddleware app
setupRedirectMiddleware app
setupErrorMiddleware app
setupJavascript404s app
###Routing function implementations###
setupJavascript404s = (app) ->
app.get '/javascripts/*', (req, res) ->
res.status(404).send('Not found')
setupFallbackRouteToIndex = (app) ->
app.all '*', (req, res) ->
fs.readFile path.join(__dirname, 'public', 'main.html'), 'utf8', (err, data) ->
log.error "Error modifying main.html: #{err}" if err
# insert the user object directly into the html so the application can have it immediately. Sanitize </script>
user = if req.user then JSON.stringify(UserHandler.formatEntity(req, req.user)).replace(/\//g, '\\/') else '{}'
Mandate.findOne({}).cache(5 * 60 * 1000).exec (err, mandate) ->
if err
log.error "Error getting mandate config: #{err}"
configData = {}
else
configData = _.omit mandate?.toObject() or {}, '_id'
configData.picoCTF = config.picoCTF
configData.production = config.isProduction
domainRegex = new RegExp("(.*\.)?(#{config.mainHostname}|#{config.unsafeContentHostname})")
domainPrefix = req.host.match(domainRegex)?[1] or ''
configData.fullUnsafeContentHostname = domainPrefix + config.unsafeContentHostname
data = data.replace '"serverConfigTag"', JSON.stringify configData
data = data.replace('"userObjectTag"', user)
data = data.replace('"amActuallyTag"', JSON.stringify(req.session.amActually))
res.header 'Cache-Control', 'no-cache, no-store, must-revalidate'
res.header 'Pragma', 'no-cache'
res.header 'Expires', 0
res.send 200, data
setupFacebookCrossDomainCommunicationRoute = (app) ->
app.get '/channel.html', (req, res) ->
res.sendfile path.join(__dirname, 'public', 'channel.html')
exports.setupRoutes = (app) ->
routes.setup(app)
app.use app.router
baseRoute.setup app
setupFacebookCrossDomainCommunicationRoute app
setupFallbackRouteToIndex app
###Miscellaneous configuration functions###
exports.setupLogging = ->
logging.setup()
exports.connectToDatabase = ->
return if config.proxy
database.connect()
exports.setupMailchimp = ->
mcapi = require 'mailchimp-api'
mc = new mcapi.Mailchimp(config.mail.mailchimpAPIKey)
GLOBAL.mc = mc
exports.setExpressConfigurationOptions = (app) ->
app.set('port', config.port)
app.set('views', __dirname + '/app/views')
app.set('view engine', 'jade')
app.set('view options', { layout: false })
app.set('env', if config.isProduction then 'production' else 'development')
app.set('json spaces', 0) if config.isProduction
setupProxyMiddleware = (app) ->
return if config.isProduction
return unless config.proxy
httpProxy = require 'http-proxy'
proxy = httpProxy.createProxyServer({
target: 'https://direct.codecombat.com'
secure: false
})
log.info 'Using dev proxy server'
app.use (req, res, next) ->
req.proxied = true
proxy.web req, res, (e) ->
console.warn("Failed to proxy: ", e)
res.status(502).send({message: 'Proxy failed'})