UNPKG

chocolate

Version:

A full stack Node.js web framework built using Coffeescript

1,002 lines (848 loc) 49.6 kB
# Here is the root of the Chocolate system. # It animates the Chocolate server *world* # by providing a **start** service that starts our World as an https server # It also provides a Session management to maintain the requestor identity between requests Https = require 'https' Http = require 'http' WebSocket = require 'ws' Fs = require 'fs' Path = require 'path' Formidable = require 'formidable' File = require './file' Interface = require './interface' Document = require './document' Reserve = require './reserve' Workflow = require '../general/locco/workflow' QueryString = require './querystring' Url = require 'url' Readable = require('stream').Readable Zlib = require('zlib') _ = require '../general/chocodash' #### Workflow sessions management # `Sessions` object will maintain a list of all session objects class Sessions constructor: (cache) -> # The sessions store is managed by the `Document.Cache` service store = @store = cache.ensure 'Locco_Workflow_Sessions_Store', {} for id, session of store then store[id] = Session.fromStore session # The `cleanup` function will be called # when a session will expire to remove it from the list cleanup = -> now = new Date ; next = Infinity for own key, object of store if object.expires < now then delete store[key] else next = if next < object.expires then next else object.expires setTimeout cleanup, if next is Infinity then 60000 else next - (+new Date) + 1000 setTimeout cleanup, 5000 # The `Get` method returns the session associated with the provided `request` # or creates a new one if none exists. get: (request) -> # Get the cookies and the remote ip address from the `request` cookies = Interface.cook request remoteAddress = request.headers['x-forwarded-for'] or request.connection.remoteAddress # createSession = true ; session = null # Did we receive a cookie ? if (browser_session_id = cookies.bsid)? # Checks if we already have a session in `store` if (session = @store[cookies.bsid])? # Is the remoteAddress the same as it was when we created the session ? # We will create a new session if not. # # If everythings ok, we record the access date # and set the session expiration date to a new date if session.remoteAddress is remoteAddress session.hasCookie = true session.lastAccess = new Date session.expires = Session.newExpiration() if session.keys?.constructor isnt {}.constructor then session.keys = {} createSession = false # If we didn't find a session in store # we create a new Session with a new id and store it in `store` if createSession browser_session_id ?= _.Uuid() session = @store[browser_session_id] ?= new Session(browser_session_id, remoteAddress) session # `Session` object keeps informations about a session # # - `id` : session unique identifier # - `remoteAddress` : requestor remote ip address for that session # - `hasCookie` : has the session already received a cookie # - `expires` : when will the session expire (on the server and in the browser) class Session constructor: (@id, @remoteAddress, @hasCookie = false, @expires = Session.newExpiration()) -> @keys = {} @lastAccess = new Date # `newExpiration`, a Session class method, calculates a new expiration date @newExpiration: -> new Date((+new Date) + 3600 * 1000) @fromStore: (o) -> session = new Session for k,v of o then session[k] = v session # add `key` to keychain addKey: (key) -> @keys[key] = null # remove `key` from keychain removeKey: (key) -> delete @keys[key] # remove all keys from keychain clearKeys: -> @keys = {} #### Workflow websockets management # `Websockets` object will maintain a list of all websocket objects class WebSockets constructor: -> @clients = {} @rooms = do -> rooms = {} register = (id, ws) -> list = rooms[id] ?= [] list.push ws unless list.indexOf(ws) >= 0 return unregister = (id, ws) -> if not ws? then ws = id; id = null if not id? and ws? rooms_to_delete = [] for name, list of rooms if (index = list.indexOf(ws)) >= 0 list.splice index, 1 if list.length is 0 then rooms_to_delete.push id for room_id in rooms_to_delete then delete rooms[room_id] else return unless (list = rooms[id])? and (index = list.indexOf(ws)) >= 0 list.splice index, 1 if list.length is 0 then delete rooms[id] return broadcast = (from_ws, id, message, send_to_self = false) -> return unless (list = rooms[id])? and list.indexOf(from_ws) >= 0 for to_ws in list when to_ws isnt from_ws or send_to_self then to_ws.send message return {register, unregister, broadcast} get: (request) -> # Get the cookies and the remote ip address from the `request` cookies = Interface.cook request if cookies.bsid? wss = @clients[cookies.bsid] ?= [] do (wss, rooms=@rooms) -> register: -> rooms.register.apply null, arguments unregister: -> rooms.unregister.apply null, arguments broadcast: -> rooms.broadcast.apply null, arguments close: -> for ws in wss then ws.close.apply ws, arguments send: -> for ws in wss then ws.send.apply ws, arguments else null register: (ws) -> cookies = Interface.cook ws.upgradeReq if cookies.bsid? #if @clients[cookies.bsid]? then console.log "client with bsid:#{cookies.bsid} has already a registered websocket" array = @clients[cookies.bsid] ?= [] array.push ws return unregister: (ws) -> cookies = Interface.cook ws.upgradeReq if cookies.bsid? array = @clients[cookies.bsid] return unless array? @rooms.unregister ws for item, i in array when item is ws array.splice i, 1 if array.length is 0 delete @clients[cookies.bsid] return #### `Sni-callback` to manage SSL # based on https://github.com/Daplie/letsencrypt-express # # todo Letsencrypt sni_callback = do -> crypto = require('crypto') tls = require('tls') create: (opts) -> ipc = {} # in-process cache # function (err, hostname, certInfo) {} handleRenewFailure = (err, hostname, certInfo) -> console.error 'ERROR: Failed to renew domain \'', hostname, '\':' if err console.error err.stack or err if certInfo console.error certInfo return assignBestByDates = (now, certInfo) -> certInfo = certInfo or loadedAt: now expiresAt: 0 issuedAt: 0 lifetime: 0 rnds = crypto.randomBytes(3) rnd1 = (rnds[0] + 1) / 257 rnd2 = (rnds[1] + 1) / 257 rnd3 = (rnds[2] + 1) / 257 # Stagger randomly by plus 0% to 25% to prevent all caches expiring at once memorizeFor = Math.floor(opts.memorizeFor + opts.memorizeFor / 4 * rnd1) # Stagger randomly to renew between n and 2n days before renewal is due # this *greatly* reduces the risk of multiple cluster processes renewing the same domain at once bestIfUsedBy = certInfo.expiresAt - (opts.renewWithin + Math.floor(opts.renewWithin * rnd2)) # Stagger randomly by plus 0 to 5 min to reduce risk of multiple cluster processes # renewing at once on boot when the certs have expired renewTimeout = Math.floor(5 * 60 * 1000 * rnd3) certInfo.loadedAt = now certInfo.memorizeFor = memorizeFor certInfo.bestIfUsedBy = bestIfUsedBy certInfo.renewTimeout = renewTimeout certInfo renewInBackground = (now, hostname, certInfo) -> if now - (certInfo.loadedAt) < opts.failedWait # wait a few minutes return if now > certInfo.bestIfUsedBy and !certInfo.timeout? # EXPIRING if now > certInfo.expiresAt # EXPIRED certInfo.renewTimeout = Math.floor(certInfo.renewTimeout / 2) if opts.debug console.log '[ChocoLetsEncrypt] skipping stagger \'' + certInfo.renewTimeout + '\' and renewing \'' + hostname + '\' now' certInfo.renewTimeout = 500 certInfo.timeout = setTimeout((-> # use all domains in `letsencrypt.domains` instead of [ hostname ] args = domains: if opts.domains? then [].concat(opts.domains) else [ hostname ] duplicate: false opts.renew args, (err, certInfo) -> if err or !certInfo opts.handleRenewFailure err, hostname, certInfo ipc[hostname] = assignBestByDates(now, certInfo) return return ), certInfo.renewTimeout) return cacheResult = (err, hostname, certInfo, sniCb) -> if certInfo? and certInfo.cert? and certInfo.tlsContext? and certInfo.cert.valid_from? if opts.debug console.log 'cert is looking good' if sniCb? then sniCb err, certInfo.tlsContext else result = certInfo.tlsContext else if opts.debug console.log 'cert is NOT looking good' sniCb? err or new Error('couldn\'t get certInfo: unknown'), null now = Date.now() certInfo = ipc[hostname] = assignBestByDates(now, certInfo) renewInBackground now, hostname, certInfo if sniCb? then return else return result registerCert = (hostname, sniCb) -> if opts.debug console.log '[ChocoLetsEncrypt] \'' + hostname + '\' is not registered, requesting approval' if !hostname sniCb? new Error('[registerCert] no hostname') return opts.approveRegistration hostname, (err, args) -> if opts.debug console.log '[ChocoLetsEncrypt] \'' + hostname + '\' registration approved, attempting register' if err cacheResult err, hostname, null, sniCb return if !(args and args.agreeTos and args.email and args.domains) cacheResult new Error('not approved or approval is missing arguments - such as agreeTos, email, domains'), hostname, null, sniCb return opts.register args, (err, certInfo) -> if opts.debug console.log '[ChocoLetsEncrypt] \'' + hostname + '\' register completed', err and err.stack or null, certInfo if (!err or !err.stack) and !certInfo console.error new Error('[ChocoLetsEncrypt] SANITY FAIL: no error and yet no certs either').stack cacheResult err, hostname, certInfo, sniCb return return return fetch = (hostname, sniCb) -> if !hostname sniCb? new Error('[sniCallback] [fetch] no hostname') return opts.fetch (certInfo, err) -> if opts.debug console.log '[ChocoLetsEncrypt] fetch from disk result \'' + hostname + '\':' if err console.error 'unable to load certificate file' else console.log 'certInfo: ' + if certInfo? then Object.keys(certInfo) else 'no certInfo available' if err sniCb? err, null return if certInfo? result = cacheResult err, hostname, certInfo, sniCb if sniCb? then return else return result registerCert hostname, sniCb return if !opts throw new Error('requires opts to be an object') if opts.debug console.log '[ChocoLetsEncrypt] creating sniCallback', JSON.stringify(opts, ((k, v) -> if v instanceof Array return JSON.stringify(v) v ), ' ') if !opts.acme throw new Error('requires opts.acme to be an ACME instance') if !opts.lifetime opts.lifetime = 90 * 24 * 60 * 60 * 1000 if !opts.failedWait opts.failedWait = 5 * 60 * 1000 if !opts.renewWithin opts.renewWithin = 3 * 24 * 60 * 60 * 1000 if !opts.memorizeFor opts.memorizeFor = 1 * 24 * 60 * 60 * 1000 if !opts.approveRegistration opts.approveRegistration = (hostname, cb) -> cb null, null return #opts.approveRegistration = function (hostname, cb) { cb(null, null); }; if !opts.handleRenewFailure opts.handleRenewFailure = handleRenewFailure (hostname, cb) -> if !hostname cb? new Error('[sniCallback] no hostname') return # use first domain name defined in `letsencrypt.domains` # as the certificate for `hostname` will be there if opts.domains then hostname = opts.domains[0] now = Date.now() certInfo = ipc[hostname] # # No cert is available in cache. # try to fetch it from disk quickly # and return to the browser # if !certInfo if opts.debug console.log '[ChocoLetsEncrypt] no certs loaded for \'' + hostname + '\'' result = fetch hostname, cb if cb? then return else return result # # A cert is available # See if it's old enough that # we should refresh it from disk # (in the background) # # once ECDSA is available, wait for cert renewal if its due (renewInBackground) if certInfo.tlsContext renewInBackground now, hostname, certInfo return if certInfo.timeout? cb? null, certInfo.tlsContext if now - (certInfo.loadedAt) < certInfo.memorizeFor # these aren't stale, so don't fall through if opts.debug console.log '[ChocoLetsEncrypt] certs for \'' + hostname + '\' are fresh from disk' if cb? then return else return certInfo.tlsContext else if now - (certInfo.loadedAt) < opts.failedWait if opts.debug console.log '[ChocoLetsEncrypt] certs for \'' + hostname + '\' recently failed and are still in cool down' # this was just fetched and failed, wait a few minutes cb? null, null return if opts.debug console.log '[ChocoLetsEncrypt] certs for \'' + hostname + '\' are stale on disk and should be will be fetched again' console.log age: now - (certInfo.loadedAt) loadedAt: certInfo.loadedAt issuedAt: certInfo.issuedAt expiresAt: certInfo.expiresAt privkey: ! !certInfo.privkey chain: ! !certInfo.chain fullchain: ! !certInfo.fullchain cert: ! !certInfo.cert memorizeFor: certInfo.memorizeFor failedWait: opts.failedWait result = fetch hostname, cb if cb? then return else return result #### The World to animate class World # Here is what we do when we create a new World ! constructor : (appdir = '.', port, key, cert) -> cwd = process.cwd() if cwd is appdir then process.chdir(__dirname + '/..') ; cwd = process.cwd() appdir = Path.relative cwd, appdir if appdir isnt '.' app_pathname = Path.resolve appdir sysdir = Path.relative app_pathname, cwd sys_pathname = cwd datadir = appdir + '/data' data_pathname = Path.resolve datadir # Get application Config for Http only server and base port, key and cert options config = require('./config')(datadir).clone() if config.log? if config.log.inFile then File.logConsoleAndErrors (appdir ? '.' ) + '/data/chocolate.log' if config.log.timestamp then File.logWithTimestamp() # If Node.js crashes unexpectedly, we will receive an `uncaughtException` and log it in a file process.on 'uncaughtException', (err) -> console.error((err and err.stack) ? new Date() + '\n' + err.stack + '\n\n' : err); Document.datadir = datadir cache = new Document.Cache async:off sessions = new Sessions cache websockets = new WebSockets space = new Reserve.Space datadir, config.extensions workflow = Workflow.main port_https = port port_https ?= config.port_https port_https ?= config.port port_https ?= 8026 port_http = config.port_http port_http ?= port_https + if config.http_only then 0 else 1 domains = config.letsencrypt?.domains if domains? hostname = domains[0] # todo Letsencrypt # There is currently a bug in letsencrypt/core.js which forces us to repeat first domain name at the end of the `domains` array # if domains.length > 1 and domains[domains.length - 1] isnt hostname then domains.push hostname config.letsencrypt.published = Fs.existsSync datadir + '/acme/' + hostname + '/fullchain.pem' if config.letsencrypt? key ?= config.key key ?= if config.letsencrypt?.published is on then 'privkey.pem' else 'privatekey.pem' cert ?= config.cert cert ?= if config.letsencrypt?.published is on then 'fullchain.pem' else 'certificate.pem' # todo Letsencrypt # LetsEncrypt = if config.letsencrypt? then require('letsencrypt') else null ACME = if config.letsencrypt? then require('@root/acme') else null to_proxy_damain = (request) -> no middleware = do -> middleware = https:[], http:[], ws:[], wss:[] if config.letsencrypt? then do -> middleware.http.push (args) -> {request, response} = args prefix = '/.well-known/acme-challenge/' if request.url.indexOf(prefix) >= 0 response.writeHead 200, { "Content-Type": "text/plain" } key = request.url.slice prefix.length try source = Fs.readFileSync Path.join(datadir + '/acme/challenge', key), 'utf8' catch then source = "" response.end source return no yes if config.proxy? then do -> proxies = {} domain_port = port_https + if config.proxy.proxy_domain? then 0 else 10 domains_proxied = switch _.type(config.proxy) when _.Type.Array then config.proxy when _.Type.Boolean, _.Type.Object then config.letsencrypt?.domains if domains_proxied? for domain in domains_proxied redirect_domain = if config.proxy.redirect? then config.proxy.redirect[domain] else null continue if proxies[domain]? if domain.substr(0,4) is 'www.' then redirect_domain = domain.substr 4 redirect_port = if redirect_domain? then proxies[redirect_domain].port else null proxies[domain] = host:redirect_domain ? domain, port:redirect_port ? domain_port domain_port += 10 unless redirect_port? HttpProxy = require 'http-proxy' proxy = HttpProxy.createProxyServer {} proxy.on 'error', (err, request, response) -> console.log 'Proxy ' + err + ' - Message:' + request.url + ' - Headers: ' + ((k + ':' + v for k,v of request.headers when k in ['host', 'user-agent']).join ', ') + '\n\n' if response.writeHead? then response.writeHead 500, {"Content-Type": if config.displayErrors then "application/json" else "text/plain"} response.end if config.displayErrors then JSON.stringify err else "" if config.proxy.log proxy.on 'proxyReq', (proxyReq, request, response) -> console.log 'proxyReq event:', 'host:' + request.headers.host, 'ip:' + request.connection?.remoteAddress, (k + ':' + v for k,v of proxyReq when k in ['method', 'path']).join ', ' proxy.on 'proxyRes', (proxyRes, request, response) -> console.log 'proxyRes event:', 'host:' + request.headers.host, 'ip:' + request.connection?.remoteAddress, (k + ':' + (if k is 'headers' then JSON.stringify(v) else v) for k,v of proxyRes when k in ['headers', 'statusCode']).join ', ' to_proxy_damain = (request) -> proxy_infos = proxies[request.headers.host] not proxy_infos? or proxy_infos.host is config.proxy.proxy_domain middleware[if config.http_only then 'ws' else 'wss'].push (args) -> {request, socket, header} = args proxy_infos = proxies[request.headers.host] if proxy_infos? and config.proxy.proxy_domain is proxy_infos.host if config.proxy.log console.log 'proxyWs event:', 'host:' + request.headers.host, 'ip:' + request.connection?.remoteAddress, (k + ':' + v for k,v of request when k in ['method', 'url']).join ', ' return yes unless proxy_infos?.port? then return yes proxy.ws request, socket, header, { target: "#{if config.http_only then 'http' else 'https'}://127.0.0.1:#{proxy_infos.port}", secure:false } no middleware[if config.http_only then 'http' else 'https'].push (args) -> {request, response} = args proxy_infos = proxies[request.headers.host] if proxy_infos? and config.proxy.proxy_domain is proxy_infos.host if config.proxy.log console.log 'proxyReq event:', 'host:' + request.headers.host, 'ip:' + request.connection?.remoteAddress, (k + ':' + v for k,v of request when k in ['method', 'url']).join ', ' return yes unless proxy_infos?.port? then response.end('') ; return no proxy_port = parseInt(proxy_infos.port) + if Url.parse(request.url).pathname.indexOf('/-/server/monitor') is 0 then 2 else 0 proxy.web request, response, { target: "#{if config.http_only then 'http' else 'https'}://127.0.0.1:#{proxy_port}", secure:false } no middleware if config.http_only for func in middleware.http then middleware.https.push func middleware.http.length = 0 # Https security options options = unless config.http_only then do -> dir = datadir + if config.letsencrypt?.published is on then '/acme/' + hostname else '' option = key: Fs.readFileSync dir + '/' + key cert: Fs.readFileSync dir + '/' + cert # todo Letsencrypt if config.letsencrypt? and config.letsencrypt.domains? and config.letsencrypt.email? debugger le_config = server: if config.letsencrypt.production is on then "https://acme-v02.api.letsencrypt.org/directory" else "https://acme-staging-v02.api.letsencrypt.org/directory" webrootPath: datadir + '/letsencrypt/root' configDir: datadir + '/letsencrypt' privkeyPath: ':config/live/:hostname/privkey.pem' fullchainPath: ':config/live/:hostname/fullchain.pem' certPath: ':config/live/:hostname/cert.pem' chainPath: ':config/live/:hostname/chain.pem' debug: config.letsencrypt.debug ? false Keypairs = require('@root/keypairs') accountKeypair = null account = null accountKey = null serverKeypair = null serverPem = null serverKey = null csr = null acme_pathname = data_pathname + '/acme' webroot_pathname = acme_pathname + '/challenge' certdir_pathname = acme_pathname + '/' + hostname acme = ACME.create maintainerEmail:config.letsencrypt.email packageAgent: (pkg = require('../package.json')).name + '/' + pkg.version notify: (event, detail) -> 'notif: ' + console.log event + ' : ' + JSON.stringify detail skipChallengeTest: yes skipDryRun: yes registerAsync = (args, cb) -> # args = { email, domains, agreeTos, debug } directoryUrl = if config.letsencrypt.production is on then "https://acme-v02.api.letsencrypt.org/directory" else "https://acme-staging-v02.api.letsencrypt.org/directory" acme.init(directoryUrl).then -> _.async (wait) -> wait (then_1) -> keypair_pathname = acme_pathname + '/keypairs.json' try source = Fs.readFileSync(keypair_pathname) if source? accountKeypair = JSON.parse source then_1() else Keypairs.generate({ kty: 'EC', format: 'jwk' }). then (result) -> accountKeypair = result Fs.mkdirSync acme_pathname unless Fs.existsSync acme_pathname Fs.writeFileSync keypair_pathname, JSON.stringify accountKeypair then_1() wait (then_1) -> accountKey = accountKeypair.private acme.accounts.create subscriberEmail:args.email agreeToTerms: args.agreeTos accountKey: accountKey .then (result) -> account = result then_1() wait (then_1) -> privkey_pathname = certdir_pathname + '/privkey.pem' try serverPem = Fs.readFileSync(privkey_pathname, 'utf8') if serverPem? Keypairs.import({ pem: serverPem }).then (result) -> serverKey = result then_1() else _.async (wait) -> wait (then_2) -> Keypairs.generate({ kty: 'RSA', format: 'jwk' }).then (result) -> serverKeypair = result ; then_2() wait (then_2) -> serverKey = serverKeypair.private Keypairs.export({ jwk: serverKey }).then (result) -> serverPem = result Fs.mkdirSync certdir_pathname unless Fs.existsSync certdir_pathname Fs.writeFileSync(privkey_pathname, serverPem, 'utf8') then_2() wait -> then_1() wait (then_1) -> punycode = require('punycode'); domains = args.domains.map (name) -> punycode.toASCII(name) CSR = require('@root/csr') PEM = require('@root/pem') CSR.csr({jwk: serverKey, domains, encoding:'der'}).then (csrDer) -> csr = PEM.packBlock({type: 'CERTIFICATE REQUEST', bytes: csrDer}) then_1() wait (then_1) -> fullchain_pathname = certdir_pathname + '/fullchain.pem' try fullchain = Fs.readFileSync(fullchain_pathname, 'utf8') if fullchain? tls = require('tls') net = require('net') secureContext = tls.createSecureContext cert: fullchain secureSocket = new tls.TLSSocket(tmpSocket = new net.Socket(), { secureContext }) cert = secureSocket.getCertificate() secureSocket.destroy() tmpSocket.destroy() return then_1() if fullchain? challenges = 'http-01': require('acme-http-01-webroot').create webroot:webroot_pathname acme.certificates.create({ account, accountKey, csr, domains, challenges }).then (pems) -> fullchain = pems.cert + '\n' + pems.chain + '\n'; Fs.writeFileSync(fullchain_pathname, fullchain, 'utf8') cb(pems) then_1() _registerHelper = (args, cb) -> if (args.debug) console.log("[ChocoLetsEncrypt]: begin registration"); registerAsync args, (pems) -> if (args.debug) console.log("[ChocoLetsEncrypt]: end registration") cb(null, pems) , cb register = (args, cb) -> if (args.debug) console.log('[ChocoLetsEncrypt] register') if (!Array.isArray(args.domains)) cb(new Error('args.domains should be an array of domains')) return # reload cert as a last check before attempting a renew fetch (hit, err) -> now = Date.now() if (err) # had a bad day cb(err) return else if (hit?) if (not args.duplicate and (now - hit.issuedAt) < ((hit.lifetime or handlers.lifetime) * 0.65)) console.warn("\ntried to renew a certificate with over 1/3 of its lifetime left, ignoring") console.warn("(use --duplicate or opts.duplicate to override\n") cb(null, hit) return _registerHelper args, (err) -> if (err) cb(err) return # Sanity Check fetch (pems, err) -> if (pems) cb(null, pems) return # still couldn't read the certs after success... that's weird console.error("still couldn't read certs after success... that's weird") cb(err, null) renew = (args, cb) -> if args.debug then console.log('[ChocoLetsEncrypt] renew') args.duplicate = no register(args, cb) return fetch = (cb) -> cert = null tlsContext = null issuedAt = expiresAt = null err = null privkey_pathname = certdir_pathname + '/privkey.pem' try privkey = Fs.readFileSync(privkey_pathname, 'utf8') fullchain_pathname = certdir_pathname + '/fullchain.pem' try fullchain = Fs.readFileSync(fullchain_pathname, 'utf8') if privkey? and fullchain? try tls = require('tls') net = require('net') tlsContext = tls.createSecureContext key: privkey, cert: fullchain secureSocket = new tls.TLSSocket(tmpSocket = new net.Socket(), { secureContext:tlsContext }) cert = secureSocket.getCertificate() issuedAt = new Date(cert.valid_from).valueOf() if cert.valid_from? expiresAt = new Date(cert.valid_to).valueOf() if cert.valid_to unless issuedAt? then cert = null secureSocket.destroy() tmpSocket.destroy() catch e cert = null err = new Error 'Unable to load certificate from ' + fullchain_pathname certInfo = { cert, tlsContext, issuedAt, expiresAt } if cert? and issuedAt? cb(certInfo, err) {email, domains, agreeTos, debug} = config.letsencrypt option.SNICallback = sni_callback.create { acme, register, renew, fetch, debug, domains, httpsOptions:_.clone(option), approveRegistration: (hostname, cb) -> cb null, {email, domains, agreeTos, debug} } [option] else [] network = if config.http_only then Http else Https extract = (request, url) -> # We extract infos from the Request request.url = url if url? # Execute rewrite rules if any if config.rewrite?.length > 0 for rewrite in config.rewrite {rule, replace} = rewrite if typeof replace is 'function' then replace = replace(url) new_url = request.url.replace new RegExp(rule, "ig"), replace (request.url = new_url ; break) if new_url isnt request.url url = Url.parse request.url query = QueryString.parse url.query, null, null, ordered:yes pathname = url.pathname extension = Path.extname pathname if (app_pathname + pathname).indexOf(sys_pathname) is 0 then pathname = '/-' + (app_pathname + pathname).substr sys_pathname.length path = pathname.split '/' keywords = ['so', 'what', 'sodowhat', 'how'] # Check if first param key isnt a keyword and then set `sodowhat` to that key param = query.list[0] if param? and param.key not in keywords and param.value is '' query.dict.sodowhat = param.key query.list.push key:'sodowhat', value:param.key query.list.splice 0,1 # `params` will contains the parameters extracted from the query part of the request params = {} only_values = true # check if we have only values and no key name in query for param, param_index in query.list when param.key not in keywords if param.value != '' then only_values = false ; break # read keys and/or values in query for param, param_index in query.list when param.key not in keywords param_key = if param.value is '' and only_values then '__' + param_index else param.key param_value = decodeURI(if param.value is '' and only_values then param.key else param.value) unless params[param_key]? params[param_key] = param_value else if _.type(params[param_key]) is _.Type.Array then params[param_key].push param_value else params[param_key] = [params[param_key], param_value] # Feed workflow with action request infos # # `so` indicates the action type. # `what` adds a precision on the action object (usualy its name). # `where` tells where the action should take place. # `how` asks for a special type of respond if available (web, raw, help). # # http://myserver/myworld/myfolder?so=move&what=/myworld/mydocument # so = move # what = /myworld/mydocument # where = /myworld/myfolder # how = web (by default) - will return an answer as Html. # # `backdoor_key` will contain the key you use to have system access # # http://myserver/!/my_backdoor_key/myworld/myfolder sodowhat = query.dict['sodowhat'] ? null so = ('do' if sodowhat?) ? query.dict['so'] ? 'go' what = sodowhat ? query.dict['what'] ? '' what_pathname = what if (app_pathname + (if what[0] isnt '/' then '/' else '' ) + what).indexOf(sys_pathname) is 0 then what_pathname = '/-' + (app_pathname + (if what[0] isnt '/' then '/' else '' ) + what).substr sys_pathname.length what_path = what_pathname.split '/' how = query.dict['how'] ? 'web' # web, edit, help, manifest, raw, json backdoor_key = if path[1] is '!' then path[2] else '' where_index = 1 + if backdoor_key isnt '' then 2 else 0 where_path = path[(where_index + if path[where_index] is '-' then 1 else 0)..] region = if path[where_index] is '-' or what_path[1] is '-' then 'system' else if where_path[0] is 'static' and how is 'web' then 'static' else if path[where_index] in ['client', 'general', 'server'] then 'secure' else 'app' where = where_path.join '/' session = sessions.get(request) websocket = websockets.get(request) {space, workflow, so, what, how, where, region, params, sysdir, appdir, datadir, backdoor_key, request, session, websocket, websockets:websocket} exchange = (bin, request, response) -> Interface.exchange bin, (result) -> if response.finished then return # We will send back a cookie with an id and an expiration date result.headers['Set-Cookie'] = 'bsid=' + bin.session.id + ';path=/;secure;httponly;expires=' + bin.session.expires.toUTCString() # And here is our response to the requestor if config.compression is on and result.status is 200 and result.headers['Content-Type']?.split(';')[0] in ['text/plain', 'text/html', 'text/javascript', 'application/json', 'application/json-late'] and request.headers['accept-encoding']?.split(',').indexOf('gzip') >= 0 result.headers ?= {} result.headers['Content-Encoding'] = 'gzip' response.writeHead result.status, result.headers read = new Readable() ; read._read = -> chunk = result.body chunk = chunk.toString() unless Buffer.isBuffer chunk read.push chunk ; read.push null read.pipe(Zlib.createGzip()).pipe response else response.writeHead result.status, result.headers response.end result.body upgrade = do -> fallback_websockets = {} Fallback_Websocket = _.prototype constructor: (@id) -> @messages = [] send: (message) -> @messages.unshift message get_websocket = (request) -> id = request.headers['x-litejq-websocket-id'] return null unless id? fallback_websockets[id] ?= new Fallback_Websocket id (request, response) -> upgraded = no empty = -> response.writeHead 200, { "Content-Type": "text/plain" } response.end '' if request.headers['x-litejq-websocket'] is 'fallback' switch request.method when 'OPTIONS' response.writeHead 200, "Content-Type": "text/plain", 'x-litejq-websocket-id': _.Uuid() response.end '' when 'POST' upgraded = true ws = get_websocket request return empty() unless ws? fields = [] form = new Formidable.IncomingForm() form .on 'field', (field, value) -> fields.push if value? and value isnt '' then value else field .on 'end', -> message = JSON.parse fields.join '' return empty() unless message? bin = extract request, message.url bin.websocket = ws bin.response = response Interface.exchange bin, (result) -> response.writeHead result.status, result.headers response.end "{result:#{result.body},id:#{message.id}}" form.parse request when 'GET' upgraded = true ws = get_websocket request return empty() unless ws? response.writeHead 200, { "Content-Type": "text/plain" } response.end if message = ws.messages.pop() then message else '' upgraded # We create an Https or Http server # (however if Letsencrypt is activated, we link our services to Http(s) servers created by Greenlock-express) # # It will receive an [HttpRequest](http://nodejs.org/docs/latest/api/all.html#http.ServerRequest) # and fills an [HttpResponse](http://nodejs.org/docs/latest/api/all.html#http.ServerResponse) server = network.createServer.apply network, options.concat (request, response) -> try # execute middleware services if any (like LetsEncrypt...) for func in middleware.https then unless func({request, response}) then return # upgrade a fallback websocket call return if upgrade request, response # Extract 'so, do, what...' infos from the Request bin = extract request bin.response = response # We call the Interface service to make an exchange between the requestor and the place specified in `where` exchange bin, request, response # If we have an error, we send it back as is. catch err response.writeHead 200, { "Content-Type": "text/plain" } response.end 'error : ' + err.stack # Handle web sockets and middleware to proxy web sockets requests server.on 'upgrade', (request, socket, header) -> if config.proxy? and not to_proxy_damain request for func in middleware[if config.http_only then 'ws' else 'wss'] then unless func({request, socket, header}) then return else ws_server.handleUpgrade request, socket, header, (client) -> ws_server.emit('connection', client, request); # Start our web server server.listen unless config.http_only and not config.letsencrypt? then port_https else port_http unless config.http_only and not config.letsencrypt? # Start Http server with redirection to Https httpApp = (request, response) -> for func in middleware.http then unless func({request, response}) then return response.writeHead 302, {'Location': "https://#{request.headers.host}#{request.url}" } response.end '' Http.createServer(httpApp).listen port_http ws_server = new WebSocket.Server({noServer:on}).on 'connection', (ws, req) -> ws.upgradeReq = req if req? websockets.register ws ws.on 'close', -> websockets.unregister ws ws.on 'message', (str) -> message = JSON.parse str bin = extract ws.upgradeReq, message.url bin.websocket = ws bin.how = how = 'json' Interface.exchange bin, (result) -> result.body ?= '' feedback = "{\"result\":#{if result.body.trim() is '' then 'null' else result.body}, \"id\":#{message.id}}" ws.send feedback return #### Start # The main workflow service. exports.start = (appdir, port) -> world = new World(appdir, port) # Services from server/Interface exports.Sessions = Sessions exports.Session = Session #### Temporary experiments exports.say_hello = (who = 'you', where = 'there') -> 'hello ' + who + ' in ' + where exports.ping_json = -> date: new Date() town: 'Paris' zip: 75000