wiki-server
Version:
A Federated Wiki Server
759 lines (653 loc) • 25.1 kB
text/coffeescript
###
* 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