UNPKG

wiki-server

Version:
759 lines (653 loc) 25.1 kB
### * Federated Wiki : Node Server * * Copyright Ward Cunningham and other contributors * Licensed under the MIT license. * https://github.com/fedwiki/wiki-server/blob/master/LICENSE.txt ### # **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 # Standard lib fs = require 'fs' path = require 'path' http = require 'http' url = require 'url' # From npm mkdirp = require 'mkdirp' express = require 'express' hbs = require 'express-hbs' glob = require 'glob' async = require 'async' f = require('flates') sanitize = require '@mapbox/sanitize-caja' fetch = require 'node-fetch' # Express 4 middleware logger = require 'morgan' cookieParser = require 'cookie-parser' methodOverride = require 'method-override' ## session = require 'express-session' sessions = require 'client-sessions' bodyParser = require 'body-parser' errorHandler = require 'errorhandler' request = require 'request' # Local files random = require './random_id' defargs = require './defaultargs' resolveClient = require 'wiki-client/lib/resolve' pluginsFactory = require './plugins' sitemapFactory = require './sitemap' searchFactory = require './search' 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) -> return '' unless story if story.type is 'paragraph' f.div {class: "item paragraph"}, f.p(resolveClient.resolveLinks(story.text)) else if story.type is 'image' f.div {class: "item image"}, f.img({class: "thumbnail", src: story.url}), f.p(resolveClient.resolveLinks(story.text or story.caption or 'uploaded image')) else if story.type is 'html' f.div {class: "item html"}, f.p(resolveClient.resolveLinks(story.text or '', sanitize)) else f.div {class: "item"}, f.p(resolveClient.resolveLinks(story.text or '')) ).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() # remove x-powered-by header app.disable('x-powered-by') # 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 = argv log = (stuff...) -> console.log stuff if argv.debug loga = (stuff...) -> console.log stuff ourErrorHandler = (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 "Already fired", error next() # Require the database adapter and initialize it with options. app.pagehandler = pagehandler = require(argv.database.type)(argv) # Require the sitemap adapter and initialize it with options. app.sitemaphandler = sitemaphandler = sitemapFactory(argv) # Require the site indexer and initialize it with options app.searchhandler = searchhandler = searchFactory(argv) # Require the security adapter and initialize it with options. app.securityhandler = securityhandler = require(argv.security_type)(log, loga, argv) # If the site is owned, owner will contain the name of the owner owner = '' # If the user is logged in, user will contain their identity user = '' # Called from authentication when the site is claimed, # to update the name of the owner held here. updateOwner = (id) -> owner = id #### Middleware #### # # Allow json to be got cross origin. cors = (req, res, next) -> res.header 'Access-Control-Allow-Origin', req.get('origin')||'*' next() remoteGet = (remote, slug, cb) -> # assume http, as we know no better at this point and we need to specify a protocol. remoteURL = new URL("http://#{remote}/#{slug}.json").toString() # set a two second timeout fetch(remoteURL, {timeout: 2000}) .then (res) -> if res.ok return res throw new Error(res.statusText) .then (res) -> return res.json() .then (json) -> cb(null, json, 200) .catch (err) -> console.error('Unable to fetch remote resource', remote, slug, err) cb(err, 'Page not found', 404) #### 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. # staticPathOptions = { dotfiles: 'ignore' etag: true immutable: false lastModified: false maxAge: '1h' } app.set('views', path.join(require.resolve('wiki-client/package.json'), '..', 'views')) app.set('view engine', 'html') app.engine('html', hbs.express4()) 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(logger('tiny')) app.use(cookieParser()) app.use(bodyParser.json({ limit: argv.uploadLimit})) app.use(bodyParser.urlencoded({ extended: true, limit: argv.uploadLimit})) app.use(methodOverride()) cookieValue = { httpOnly: true sameSite: 'lax' } if argv.wiki_domain if !argv.wiki_domain.endsWith('localhost') cookieValue['domain'] = argv.wiki_domain # use secureProxy as TLS is terminated in outside the node process if argv.secure_cookie cookieName = 'wikiTlsSession' cookieValue['secureProxy'] = true else cookieName = "wikiSession" app.use(sessions({ cookieName: cookieName, requestKey: 'session', secret: argv.cookieSecret, # make the session session_duration days long duration: argv.session_duration * 24 * 60 * 60 * 1000, # add 12 hours to session if less than 12 hours to expiry activeDuration: 24 * 60 * 60 * 1000, cookie: cookieValue })) app.use(ourErrorHandler) # Add static route to the client app.use(express.static(argv.client, staticPathOptions)) ##### Define security routes ##### securityhandler.defineRoutes app, cors, updateOwner # Add static route to assets app.use('/assets', cors, express.static(argv.assets)) # 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), staticPathOptions)) # Add static routes to the security client. if argv.security != './security' app.use('/security', express.static(path.join(argv.packageDir, argv.security_type, 'client'), staticPathOptions)) ##### Set up standard environments. ##### # In dev mode turn on console.log debugging as well as showing the stack on err. if 'development' == app.get('env') app.use(errorHandler()) argv.debug = console? and true # Show all of the options a server is using. log argv #### 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, next) -> urlPages = (i for i in req.params[0].split('/') by 2)[1..] urlLocs = (j for j in req.params[0].split('/')[1..] by 2) if ['plugin', 'auth'].indexOf(urlLocs[0]) > -1 return next() title = urlPages[..].pop().replace(/-+/g,' ') user = securityhandler.getUser(req) info = { title pages: [] authenticated: if user true else false user: user seedNeighbors: argv.neighbors owned: if owner true else false isOwner: if securityhandler.isAuthorized(req) true else false ownedBy: if owner owner else '' } 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) -> slug = req.params[0] log(slug) if slug is 'runtests' return next() pagehandler.get slug, (e, page, status) -> if e then return res.e e if status is 404 return res.status(status).send(page) page.title ||= slug.replace(/-+/g,' ') page.story ||= [] user = securityhandler.getUser(req) info = { title: page.title pages: [ page: slug generated: """data-server-generated=true""" story: render(page) ] authenticated: if user true else false user: user seedNeighbors: argv.neighbors owned: if owner true else false isOwner: if securityhandler.isAuthorized(req) true else false ownedBy: if owner owner else '' } 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) doFactories = (file, cb) -> fs.readFile file, (err, data) -> return cb() if err try factory = JSON.parse data cb null, factory catch err return cb() async.map files, doFactories, (e, factories) -> res.e(e) if e res.end(JSON.stringify factories) ###### 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.status(status or 200).send(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.status(status or 200).send(page) ###### Theme Routes ###### # If themes doesn't exist send 404 and let the client # deal with it. app.get /^\/theme\/(\w+\.\w+)$/, cors, (req,res) -> res.sendFile(path.join(argv.status, 'theme', req.params[0]), (e) -> if (e) # swallow the error if the theme does not exist... if req.path is '/theme/style.css' res.set('Content-Type', 'text/css') res.send('') else res.sendStatus(404) ) ###### Favicon Routes ###### # If favLoc doesn't exist send the default favicon. favLoc = path.join(argv.status, 'favicon.png') defaultFavLoc = path.join(argv.root, 'default-data', 'status', 'favicon.png') app.get '/favicon.png', cors, (req,res) -> fs.exists favLoc, (exists) -> if exists res.sendFile(favLoc) else res.sendFile(defaultFavLoc) authorized = (req, res, next) -> if securityhandler.isAuthorized(req) next() else console.log 'rejecting', req.path res.sendStatus(403) # Accept favicon image posted to the server, and if it does not already exist # save it. app.post '/favicon.png', authorized, (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) ###### Recycler Routes ###### # These routes are only available to the site's owner # Give the recycler a standard flag - use the Taiwan symbol as the use of # negative space outward pointing arrows nicely indicates that items can be removed recyclerFavLoc = path.join(argv.root, 'default-data', 'status', 'recycler.png') app.get '/recycler/favicon.png', authorized, (req, res) -> res.sendFile(recyclerFavLoc) # Send an array of pages currently in the recycler via json app.get '/recycler/system/slugs.json', authorized, (req, res) -> fs.readdir argv.recycler, (e, files) -> doRecyclermap = (file, cb) -> recycleFile = 'recycler/' + file pagehandler.get recycleFile, (e, page, status) -> if e or status is 404 console.log 'Problem building recycler map:', file, 'e: ',e # this will leave an undefined/empty item in the array, which we will filter out later return cb() cb null, { slug: file title: page.title } if e then return res.e e async.map files, doRecyclermap, (e, recyclermap) -> return cb(e) if e # remove any empty items recyclermap = recyclermap.filter( (el) -> return !!el ) res.send(recyclermap) # Fetching page from the recycler #///^/([a-z0-9-]+)\.json$/// app.get ///^/recycler/([a-z0-9-]+)\.json$///, authorized, (req, res) -> file = 'recycler/' + req.params[0] pagehandler.get file, (e, page, status) -> if e then return res.e e res.status(status or 200).send(page) # Delete page from the recycler app.delete ///^/recycler/([a-z0-9-]+)\.json$///, authorized, (req, res) -> file = 'recycler/' + req.params[0] pagehandler.delete file, (err) -> if err then res.status(500).send(err) res.status(200).send('') ###### Meta Routes ###### # Send an array of pages in the database via json app.get '/system/slugs.json', cors, (req, res) -> pagehandler.slugs (err, files) -> if err then res.status(500).send(err) 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) # sitemapLoc = path.join(argv.status, 'sitemap.json') app.get '/system/sitemap.json', cors, (req, res) -> fs.exists sitemapLoc, (exists) -> if exists res.sendFile(sitemapLoc) else # only createSitemap if we are not already creating one sitemaphandler.createSitemap (pagehandler) if !sitemaphandler.isWorking() # wait for the sitemap file to be written, before sending sitemaphandler.once 'finished', -> res.sendFile(sitemapLoc) xmlSitemapLoc = path.join(argv.status, 'sitemap.xml') app.get '/sitemap.xml', (req, res) -> fs.exists sitemapLoc, (exists) -> if exists res.sendFile(xmlSitemapLoc) else sitemaphandler.createSitemap (pagehandler) if !sitemaphandler.isWorking() sitemaphandler.once 'finished', -> res.sendFile(xmlSitemapLoc) searchIndexLoc = path.join(argv.status, 'site-index.json') app.get '/system/site-index.json', cors, (req, res) -> fs.exists searchIndexLoc, (exists) -> if exists res.sendFile(searchIndexLoc) else # only create index if we are not already creating one searchhandler.createIndex(pagehandler) if !searchhandler.isWorking() searchhandler.once 'indexed', -> res.sendFile(searchIndexLoc) app.get '/system/export.json', cors, (req, res) -> pagehandler.pages (e, sitemap) -> return res.e(e) if e async.map( sitemap, (stub, done) -> pagehandler.get(stub.slug, (error, page) -> return done(e) if e done(null, {slug: stub.slug, page}) ) , (e, pages) -> return res.e(e) if e res.json(pages.reduce( (dict, combined) -> dict[combined.slug] = combined.page dict , {})) ) admin = (req, res, next) -> if securityhandler.isAdmin(req) next() else console.log 'rejecting', req.path res.sendStatus(403) app.get '/system/version.json', admin, (req, res) -> versions = {} wikiModule = module.parent.parent.parent versions[wikiModule.require('./package').name] = wikiModule.require('./package').version versions[wikiModule.require('wiki-server/package').name] = wikiModule.require('wiki-server/package').version versions[wikiModule.require('wiki-client/package').name] = wikiModule.require('wiki-client/package').version versions['security'] = {} versions['plugins'] = {} glob '+(wiki-security-*|wiki-plugin-*)', {cwd: argv.packageDir}, (e, plugins) -> plugins.map (plugin) -> if plugin.includes 'wiki-security' versions.security[wikiModule.require(plugin + "/package").name] = wikiModule.require(plugin + "/package").version else versions.plugins[wikiModule.require(plugin + "/package").name] = wikiModule.require(plugin + "/package").version res.json(versions) ##### Proxy routes ##### app.get '/proxy/*', authorized, (req, res) -> pathParts = req.originalUrl.split('/') remoteHost = pathParts[2] pathParts.splice(0,3) remoteResource = pathParts.join('/') requestURL = 'http://' + remoteHost + '/' + remoteResource console.log("PROXY Request: ", requestURL) if requestURL.endsWith('.json') or requestURL.endsWith('.png') or pathParts[0] is "plugin" requestOptions = { host: remoteHost port: 80 path: remoteResource } try request .get(requestURL, requestOptions) .on('error', (err) -> console.log("ERROR: Request ", requestURL, err)) .pipe(res) catch error console.log "PROXY Error", error res.status(500).end() else res.status(400).end() ##### Put routes ##### app.put /^\/page\/([a-z0-9-]+)\/action$/i, authorized, (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.status(status).send(page) # Using Coffee-Scripts implicit returns we assign page.story to the # result of a list comprehension by way of a switch expression. try # save the original page, so we can remove it from the index. origStory = Object.assign([], page.story) or [] 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 throw('Unfamiliar action ignored') 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' # update sitemap sitemaphandler.update(req.params[0], page) # update site index searchhandler.update(req.params[0], page, origStory) # log action # If the action is a fork, get the page from the remote server, # otherwise ask pagehandler for it. if action.fork pagehandler.saveToRecycler req.params[0], (err) -> if err and err isnt 'page does not exist' console.log "Error saving #{req.params[0]} before fork: #{err}" 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.status(409).send('Page already exists.') else actionCB(null, itemCopy) else if action.type == 'fork' pagehandler.saveToRecycler req.params[0], (err) -> if err then console.log "Error saving #{req.params[0]} before fork: #{err}" 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) -> home = path.join argv.assets, 'home', 'index.html' fs.stat home, (err, stats) -> if err || !stats.isFile() res.redirect(index) else res.redirect("/assets/home/index.html") ##### Delete Routes ##### app.delete ///^/([a-z0-9-]+)\.json$///, authorized, (req, res) -> pageFile = req.params[0] # we need the original page text to remove it from the index, so get the original text before deleting it pagehandler.get pageFile, (e, page, status) -> title = page.title origStory = Object.assign([], page.story) or [] pagehandler.delete pageFile, (err) -> if err res.status(500).send(err) else sitemaphandler.removePage pageFile res.status(200).send('') # update site index searchhandler.removePage(req.params[0], title, origStory) #### Start the server #### # Wait to make sure owner is known before listening. securityhandler.retrieveOwner (e) -> # Throw if you can't find the initial owner if e then throw e owner = securityhandler.getOwner() console.log "owner: " + owner app.emit 'owner-set' app.on 'running-serv', (server) -> ### Plugins ### # Should replace most WebSocketServers below. plugins = pluginsFactory(argv) plugins.startServers({argv, app}) ### Sitemap ### # create sitemap at start-up sitemaphandler.createSitemap(pagehandler) # create site index at start-up searchhandler.startUp(pagehandler) # Return app when called, so that it can be watched for events and shutdown with .close() externally. app