UNPKG

wiki-server

Version:
510 lines (434 loc) 16.3 kB
# **server.coffee** is the main guts of the express version # of (Smallest Federated Wiki)[https://github.com/WardCunningham/Smallest-Federated-Wiki]. # The CLI and Farm are just front ends # for setting arguments, and spawning servers. In a complex system # you would probably want to replace the CLI/Farm with your own code, # and use server.coffee directly. # #### Dependencies #### # anything not in the standard library is included in the repo, or # can be installed with an: # npm install require('coffee-trace') # Standard lib fs = require 'fs' path = require 'path' http = require 'http' child_process = require 'child_process' spawn = child_process.spawn # From npm mkdirp = require 'mkdirp' express = require 'express' hbs = require 'hbs' glob = require 'glob' es = require 'event-stream' JSONStream = require 'JSONStream' async = require 'async' f = require('flates') # Local files random = require './random_id' defargs = require './defaultargs' wiki = require 'wiki-client/lib/wiki' pluginsFactory = require './plugins' Persona = require './persona_auth' render = (page) -> return f.div({class: "twins"}, f.p('')) + '\n' + f.div({class: "header"}, f.h1( f.a({href: '/', style: 'text-decoration: none'}, f.img({height: '32px', src: '/favicon.png'})) + ' ' + page.title)) + '\n' + f.div {class: "story"}, page.story.map((story) -> if story.type is 'paragraph' f.div {class: "item paragraph"}, f.p(story.text) else if story.type is 'image' f.div {class: "item image"}, f.img({class: "thumbnail", src: story.url}), f.p(story.text or story.caption or 'uploaded image') else f.div {class: "item error"}, f.p(story.type) ).join('\n') # Set export objects for node and coffee to a function that generates a sfw server. module.exports = exports = (argv) -> # Create the main application object, app. app = express() # defaultargs.coffee exports a function that takes the argv object # that is passed in and then does its # best to supply sane defaults for any arguments that are missing. argv = defargs(argv) app.startOpts = do -> options = {} for own k, v of argv options[k] = v options log = (stuff...) -> console.log stuff if argv.debug loga = (stuff...) -> console.log stuff errorHandler = (req, res, next) -> fired = false res.e = (error, status) -> if !fired fired = true res.statusCode = status or 500 res.end 'Server ' + error log "Res sent:", res.statusCode, error else log "Allready fired", error next() # Require the database adapter and initialize it with options. app.pagehandler = pagehandler = require(argv.database.type)(argv) #### Setting up Authentication #### # The owner of a server is simply the open id url that the wiki # has been claimed with. It is persisted at argv.status/open_id.identity, # and kept in memory as owner. A falsy owner implies an unclaimed wiki. owner = '' # Attempt to figure out if the wiki is claimed or not, # if it is return the owner, if not set the owner # to the id if it is provided. setOwner = (id, cb) -> fs.exists argv.id, (exists) -> if exists fs.readFile(argv.id, (err, data) -> if err then return cb err owner += data cb()) else if id fs.writeFile(argv.id, id, (err) -> if err then return cb err loga "Claimed by #{id}" owner = id cb()) else cb() #### Middleware #### # # Allow json to be got cross origin. cors = (req, res, next) -> res.header('Access-Control-Allow-Origin', '*') next() remoteGet = (remote, slug, cb) -> [host, port] = remote.split(':') getopts = { host: host port: port or 80 path: "/#{slug}.json" } # TODO: This needs more robust error handling, just trying to # keep it from taking down the server. http.get(getopts, (resp) -> responsedata = '' resp.on 'data', (chunk) -> responsedata += chunk resp.on 'error', (e) -> cb(e, 'Page not found', 404) resp.on 'end', -> if resp.statusCode == 404 cb(null, 'Page not found', 404) else if responsedata cb(null, JSON.parse(responsedata), resp.statusCode) else cb(null, 'Page not found', 404) ).on 'error', (e) -> cb(e, 'Page not found', 404) persona = Persona(log, loga, argv) # Persona middleware needs access to this module's owner variable getOwner = -> owner #### Express configuration #### # Set up all the standard express server options, # including hbs to use handlebars/mustache templates # saved with a .html extension, and no layout. app.configure -> app.set('views', path.join(__dirname, '..', '/views')) app.set('view engine', 'html') app.engine('html', hbs.__express) app.set('view options', layout: false) # use logger, at least in development, probably needs a param to configure (or turn off). # use stream to direct to somewhere other than stdout. app.use(express.logger('tiny')) app.use(express.cookieParser()) app.use(express.bodyParser()) app.use(express.methodOverride()) app.use(express.session({ secret: 'notsecret'})) app.use(persona.authenticate_session(getOwner)) app.use(errorHandler) app.use(app.router) # Add static route to the client app.use(express.static(argv.client)) # Add static routes to the plugins client. glob "wiki-plugin-*/client", {cwd: argv.packageDir}, (e, plugins) -> plugins.map (plugin) -> pluginName = plugin.slice(12, -7) pluginPath = '/plugins/' + pluginName app.use(pluginPath, express.static(path.join(argv.packageDir, plugin))) ##### Set up standard environments. ##### # In dev mode turn on console.log debugging as well as showing the stack on err. app.configure 'development', -> app.use(express.errorHandler({ dumpExceptions: true, showStack: true })) argv.debug = console? and true # Show all of the options a server is using. log argv # Swallow errors when in production. app.configure 'production', -> app.use(express.errorHandler()) # authenticated indicates that we have a logged in user. # The req.isAuthenticated returns true on an unclaimed wiki # so we must also check that we have a logged in user is_authenticated = (req) -> if req.isAuthenticated() if !! req.session.email return true return false #### Routes #### # Routes currently make up the bulk of the Express port of # Smallest Federated Wiki. Most routes use literal names, # or regexes to match, and then access req.params directly. ##### Redirects ##### # Common redirects that may get used throughout the routes. index = argv.home + '.html' oops = '/oops' ##### Get routes ##### # Routes have mostly been kept together by http verb, with the exception # of the openID related routes which are at the end together. # Main route for initial contact. Allows us to # link into a specific set of pages, local and remote. # Can also be handled by the client, but it also sets up # the login status, and related footer html, which the client # relies on to know if it is logged in or not. app.get ///^((/[a-zA-Z0-9:.-]+/[a-z0-9-]+(_rev\d+)?)+)/?$///, (req, res) -> urlPages = (i for i in req.params[0].split('/') by 2)[1..] urlLocs = (j for j in req.params[0].split('/')[1..] by 2) info = { pages: [] authenticated: is_authenticated(req) user: req.session.email ownedBy: if owner 'Site owned by ' + owner.substr(0, owner.indexOf('@')) else '' loginStatus: if owner if req.isAuthenticated() 'logout' else 'login' else 'claim' loginBtnTxt: if owner if req.isAuthenticated() 'Sign out' else 'Sign in with your Email' else 'Claim with your Email' } for page, idx in urlPages if urlLocs[idx] is 'view' pageDiv = {page} else pageDiv = {page, origin: """data-site=#{urlLocs[idx]}"""} info.pages.push(pageDiv) res.render('static.html', info) app.get ///([a-z0-9-]+)\.html$///, (req, res, next) -> file = req.params[0] log(file) if file is 'runtests' return next() pagehandler.get file, (e, page, status) -> if e then return res.e e if status is 404 return res.send page, status info = { pages: [ page: file generated: """data-server-generated=true""" story: wiki.resolveLinks(render(page)) ] user: req.session.email authenticated: is_authenticated(req) ownedBy: if owner 'Site owned by ' + owner.substr(0, owner.indexOf('@')) else '' loginStatus: if owner if req.isAuthenticated() 'logout' else 'login' else 'claim' loginBtnTxt: if owner if req.isAuthenticated() 'Sign out' else 'Sign in with your Email' else 'Claim with your Email' } res.render('static.html', info) app.get ///system/factories.json///, (req, res) -> res.status(200) res.header('Content-Type', 'application/json') # Plugins are located in packages in argv.packageDir, with package names of the form wiki-plugin-* glob path.join(argv.packageDir, 'wiki-plugin-*', 'factory.json'), (e, files) -> if e then return res.e(e) files = files.map (file) -> return fs.createReadStream(file).on('error', res.e).pipe(JSONStream.parse()) es.concat.apply(null, files) .on('error', res.e) .pipe(JSONStream.stringify()) .pipe(res) ###### Json Routes ###### # Handle fetching local and remote json pages. # Local pages are handled by the pagehandler module. app.get ///^/([a-z0-9-]+)\.json$///, cors, (req, res) -> file = req.params[0] pagehandler.get file, (e, page, status) -> if e then return res.e e res.send(status or 200, page) # Remote pages use the http client to retrieve the page # and sends it to the client. TODO: consider caching remote pages locally. app.get ///^/remote/([a-zA-Z0-9:\.-]+)/([a-z0-9-]+)\.json$///, (req, res) -> remoteGet req.params[0], req.params[1], (e, page, status) -> if e log "remoteGet error:", e return res.e e res.send(status or 200, page) ###### Favicon Routes ###### # If favLoc doesn't exist send 404 and let the client # deal with it. favLoc = path.join(argv.status, 'favicon.png') app.get '/favicon.png', cors, (req,res) -> res.sendfile(favLoc) authenticated = (req, res, next) -> if req.isAuthenticated() next() else console.log 'rejecting', req.path res.send(403) # Accept favicon image posted to the server, and if it does not already exist # save it. app.post '/favicon.png', authenticated, (req, res) -> favicon = req.body.image.replace(///^data:image/png;base64,///, "") buf = new Buffer(favicon, 'base64') fs.exists argv.status, (exists) -> if exists fs.writeFile favLoc, buf, (e) -> if e then return res.e e res.send('Favicon Saved') else mkdirp argv.status, -> fs.writeFile favLoc, buf, (e) -> if e then return res.e e res.send('Favicon Saved') # Redirect remote favicons to the server they are needed from. app.get ///^/remote/([a-zA-Z0-9:\.-]+/favicon.png)$///, (req, res) -> remotefav = "http://#{req.params[0]}" res.redirect(remotefav) ###### Meta Routes ###### # Send an array of pages in the database via json app.get '/system/slugs.json', cors, (req, res) -> fs.readdir argv.db, (e, files) -> if e then return res.e e res.send(files) # Returns a list of installed plugins. (does this get called anymore!) app.get '/system/plugins.json', cors, (req, res) -> glob "wiki-plugin-*", {cwd: argv.packageDir}, (e, files) -> if e then return res.e e # extract the plugin name from the name of the directory it's installed in files = files.map (file) -> file.slice(12) res.send(files) app.get '/system/sitemap.json', cors, (req, res) -> pagehandler.pages (e, sitemap) -> return res.e(e) if e res.json(sitemap) app.post '/persona_login', cors, persona.verify_assertion(getOwner, setOwner) app.post '/persona_logout', cors, (req, res) -> req.session.destroy (err) -> res.send(err || "OK") ##### Put routes ##### app.put /^\/page\/([a-z0-9-]+)\/action$/i, authenticated, (req, res) -> action = JSON.parse(req.body.action) # Handle all of the possible actions to be taken on a page, actionCB = (e, page, status) -> #if e then return res.e e if status is 404 res.send(page, status) # Using Coffee-Scripts implicit returns we assign page.story to the # result of a list comprehension by way of a switch expression. try page.story = switch action.type when 'move' action.order.map (id) -> page.story.filter((para) -> id == para.id )[0] or throw('Ignoring move. Try reload.') when 'add' idx = page.story.map((para) -> para.id).indexOf(action.after) + 1 page.story.splice(idx, 0, action.item) page.story when 'remove' page.story.filter (para) -> para?.id != action.id when 'edit' page.story.map (para) -> if para.id is action.id action.item else para when 'create', 'fork' page.story or [] else log "Unfamiliar action:", action page.story catch e return res.e e # Add a blank journal if it does not exist. # And add what happened to the journal. if not page.journal page.journal = [] if action.fork page.journal.push({type: "fork", site: action.fork}) delete action.fork page.journal.push(action) pagehandler.put req.params[0], page, (e) -> if e then return res.e e res.send('ok') log 'saved' log action # If the action is a fork, get the page from the remote server, # otherwise ask pagehandler for it. if action.fork remoteGet(action.fork, req.params[0], actionCB) else if action.type is 'create' # Prevent attempt to write circular structure itemCopy = JSON.parse(JSON.stringify(action.item)) pagehandler.get req.params[0], (e, page, status) -> if e then return actionCB(e) unless status is 404 res.send('Page already exists.', 409) else actionCB(null, itemCopy) else if action.type == 'fork' if action.item # push itemCopy = JSON.parse(JSON.stringify(action.item)) delete action.item actionCB(null, itemCopy) else # pull remoteGet(action.site, req.params[0], actionCB) else pagehandler.get(req.params[0], actionCB) # Return the oops page when login fails. app.get '/oops', (req, res) -> res.statusCode = 403 res.render('oops.html', {msg:'This is not your wiki!'}) # Traditional request to / redirects to index :) app.get '/', (req, res) -> res.redirect(index) #### Start the server #### # Wait to make sure owner is known before listening. setOwner null, (e) -> # Throw if you can't find the initial owner if e then throw e server = app.listen argv.port, argv.host, -> app.emit 'listening' loga "Smallest Federated Wiki server listening on", argv.port, "in mode:", app.settings.env ### Plugins ### # Should replace most WebSocketServers below. plugins = pluginsFactory(argv) plugins.startServers({server: server, argv}) # Return app when called, so that it can be watched for events and shutdown with .close() externally. app