wiki-server
Version:
A Federated Wiki Server
1,027 lines (931 loc) • 32.7 kB
JavaScript
/*
* 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
import fs from 'fs'
import path from 'path'
import url from 'url'
import { pipeline } from 'node:stream/promises'
// From npm
import express from 'express'
import hbs from 'express-hbs'
import f from 'flates'
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)
// Using native fetch API (available in Node.js 18+)
// Express 4 middleware
import logger from 'morgan'
import cookieParser from 'cookie-parser'
import methodOverride from 'method-override'
// session = require('express-session') // This one was commented out — uncomment if used
import sessions from 'client-sessions'
import bodyParser from 'body-parser'
import errorHandler from 'errorhandler'
// Local files
// Make sure these files are ESM modules or compatible
// If they are CommonJS, you will need to dynamically import them (see below)
import defargs from './defaultargs.js'
import resolveClient from 'wiki-client/lib/resolve.js'
import pluginsFactory from './plugins.js'
import sitemapFactory from './sitemap.js'
import searchFactory from './search.js'
import { warn } from 'node:console'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
// Use import to load package.json from the main application's working directory
//const { default: packageJson } = await import('wiki/package.json', { with: { type: 'json' } })
const wikiPackageImport = async () => {
let done = false
return new Promise(resolve => {
import('wiki/package.json', { with: { type: 'json' } })
.then(imported => {
done = true
resolve(imported.default)
})
.catch(e => {
return e
})
.then(async () => {
if (done) return
const packageJsonPath = path.join(process.cwd(), 'package.json')
const packageJsonUrl = url.pathToFileURL(packageJsonPath).href
import(packageJsonUrl, { with: { type: 'json' } })
.then(imported => {
resolve(imported.default)
})
.catch(e => console.error('problems importing package', e))
})
})
}
const packageJson = await wikiPackageImport()
const 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) return ''
if (story.type === 'paragraph') {
f.div({ class: 'item paragraph' }, f.p(resolveClient.resolveLinks(story.text)))
} else if (story.type === 'image') {
f.div(
{ class: 'item image' },
f.img({ class: 'thumbnail', src: story.url }),
f.p(resolveClient.resolveLinks(story.text || story.caption || 'uploaded image')),
)
} else if (story.type === 'html') {
f.div({ class: 'item html' }, f.p(resolveClient.resolveLinks(story.text || '', DOMPurify.sanitize)))
} else f.div({ class: 'item' }, f.p(resolveClient.resolveLinks(story.text || '')))
})
.join('\n'),
)
)
}
// Set export objects for node and coffee to a function that generates a sfw server.
export default async argv => {
// Create the main application object, app.
const 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
const wikiName = new URL(argv.url).hostname
const log = (...stuff) => {
if (argv.debug) console.log(stuff)
}
const loga = (...stuff) => {
console.log(stuff)
}
const ourErrorHandler = (req, res, next) => {
let fired = false
res.e = (error, status) => {
if (!fired) {
fired = true
res.statusCode = status || 500
res.end('Server ' + error)
log('Res sent:', res.statusCode, error)
} else {
log('Already fired', error)
}
}
next()
}
let pagehandler, sitemaphandler, searchhandler, securityhandler
// Dynamically import database adapter (since the module name is dynamic)
const dbModule = await import(argv.database.type)
app.pagehandler = pagehandler = dbModule.default(argv)
// Initialize sitemap handler
app.sitemaphandler = sitemaphandler = sitemapFactory(argv)
// Initialize search handler
app.searchhandler = searchhandler = searchFactory(argv)
// Dynamically import security adapter (also dynamic)
console.log('security_type', argv.security_type)
const securityModule = await import(argv.security_type)
app.securityhandler = securityhandler = securityModule.default(log, loga, argv)
// If the site is owned, owner will contain the name of the owner
let owner = ''
// If the user is logged in, user will contain their identity
let user = ''
// Called from authentication when the site is claimed,
// to update the name of the owner held here.
const updateOwner = id => {
owner = id
}
// #### Middleware ####
//
// Allow json to be got cross origin.
const cors = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.get('origin') || '*')
next()
}
const remoteGet = (remote, slug, cb) => {
// assume http, as we know no better at this point and we need to specify a protocol.
const remoteURL = new URL(`http://${remote}/${slug}.json`).toString()
// set a two second timeout
fetch(remoteURL, { signal: AbortSignal.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.
//
const 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 })
// return deterministically colored strings
const colorString = str => {
const colorReset = '\x1b[0m'
let hash = 0
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let color = '\x1b[38;2'
;[...Array(3).keys()].forEach(i => {
const value = (hash >> (i * 8)) & 0xff
color += ':' + value.toString()
})
color += 'm'
return color + str + colorReset
}
// use logger, at least in development, probably needs a param to configure (or turn off).
// use stream to direct to somewhere other than stdout.
const vhost = colorString(wikiName)
app.use(
logger((tokens, req, res) => {
return [
vhost,
tokens.method(req, res),
tokens.url(req, res),
tokens.status(req, res),
tokens.res(req, res, 'content-length'),
'-',
tokens['response-time'](req, res),
'ms',
].join(' ')
}),
)
app.use(cookieParser())
app.use(bodyParser.json({ limit: argv.uploadLimit }))
app.use(bodyParser.urlencoded({ extended: true, limit: argv.uploadLimit }))
app.use(methodOverride())
const 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
let cookieName
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.
Object.keys(packageJson.dependencies)
.filter(depend => depend.startsWith('wiki-plugin'))
.forEach(plugin => {
const clientPath = path.join(path.dirname(require.resolve(`${plugin}/package`)), 'client')
const pluginPath = '/plugins/' + plugin.slice(12)
app.use(pluginPath, cors, express.static(clientPath, 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 = 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.
const index = argv.home + '.html'
const 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+)?)+)\/?$/, cors, (req, res, next) => {
const urlPages = req.params[0]
.split('/')
.filter((_, index) => index % 2 === 0)
.slice(1)
const urlLocs = req.params[0]
.split('/')
.slice(1)
.filter((_, index) => index % 2 === 0)
if (['plugin', 'auth'].indexOf(urlLocs[0]) > -1) {
return next()
}
const title = urlPages.slice().pop().replace(/-+/g, ' ')
user = securityhandler.getUser(req)
const info = {
title,
pages: [],
authenticated: user ? true : false,
user: user,
seedNeighbors: argv.neighbors,
owned: owner ? true : false,
isOwner: securityhandler.isAuthorized(req) ? true : false,
ownedBy: owner ? owner : '',
}
for (const [idx, page] of urlPages.entries()) {
let pageDiv
if (urlLocs[idx] === '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$/, cors, (req, res, next) => {
const slug = req.params[0]
log(slug)
if (slug === 'runtests') return next()
pagehandler.get(slug, (e, page, status) => {
if (e) {
return res.e(e)
}
if (status === 404) {
return res.status(status).send(page)
}
page.title ||= slug.replace(/-+/g, ' ')
page.story ||= []
user = securityhandler.getUser(req)
const info = {
title: page.title,
pages: [
{
page: slug,
generated: 'data-server-generated=true',
story: render(page),
},
],
authenticated: user ? true : false,
user: user,
seedNeighbors: argv.neighbors,
owned: owner ? true : false,
isOwner: securityhandler.isAuthorized(req) ? true : false,
ownedBy: owner ? owner : '',
}
res.render('static.html', info)
})
})
app.get('/system/factories.json', (req, res) => {
res.status(200)
res.header('Content-Type', 'application/json')
const factories = []
const getPackageFactory = plugin => {
return new Promise(resolve => {
import(`${plugin}/factory.json`, { with: { type: 'json' } })
.then(({ default: factory }) => {
resolve(factories.push(factory))
})
.catch(() => {
resolve()
})
})
}
// in test (wiki-server) the plugins are listed as devDependencues, so we need to fallback to
// using devDEpendencies when there are no plugins in dependencies.
const dependencyPlugins = Object.keys(packageJson.dependencies).filter(depend => depend.startsWith('wiki-plugin'))
const plugins = dependencyPlugins.length
? dependencyPlugins
: Object.keys(packageJson.devDependencies).filter(depend => depend.startsWith('wiki-plugin'))
Promise.all(
plugins.map(plugin => {
return getPackageFactory(plugin)
}),
).then(() => 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) => {
const file = req.params[0]
pagehandler.get(file, (e, page, status) => {
if (e) {
return res.e(e)
}
res.status(status || 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 || 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]), { dotfiles: 'allow' }, e => {
if (e) {
// swallow the error if the theme does not exist...
if (req.path === '/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.
const favLoc = path.join(argv.status, 'favicon.png')
const defaultFavLoc = path.join(argv.root, 'default-data', 'status', 'favicon.png')
app.get('/favicon.png', cors, (req, res) => {
fs.access(favLoc, fs.constants.F_OK, err => {
if (!err) {
res.sendFile(favLoc, { dotfiles: 'allow' })
} else {
res.sendFile(defaultFavLoc, { dotfiles: 'allow' })
}
})
})
const 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) => {
const favicon = req.body.image.replace(/^data:image\/png;base64,/, '')
const buf = Buffer.from(favicon, 'base64')
fs.access(argv.status, fs.constants.F_OK, err => {
if (!err) {
fs.writeFile(favLoc, buf, e => {
if (e) {
return res.e(e)
}
res.send('Favicon Saved')
})
} else {
fs.mkdir(argv.status, { recursive: true }, () => {
fs.writeFile(favLoc, buf, e => {
if (e) {
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) => {
const 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
const recyclerFavLoc = path.join(argv.root, 'default-data', 'status', 'recycler.png')
app.get('/recycler/favicon.png', authorized, (req, res) => {
res.sendFile(recyclerFavLoc, { dotfiles: 'allow' })
})
// 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) => {
if (e) {
return res.e(e)
}
const doRecyclermap = async file => {
return new Promise(resolve => {
const recycleFile = 'recycler/' + file
pagehandler.get(recycleFile, (e, page, status) => {
if (e || status === 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 resolve(null)
}
resolve({
slug: file,
title: page.title,
})
})
})
}
Promise.all(files.map(doRecyclermap))
.then(recyclermap => {
recyclermap = recyclermap.filter(el => !!el)
res.send(recyclermap)
})
.catch(error => {
res.e(error)
})
})
})
// Fetching page from the recycler
/////^/([a-z0-9-]+)\.json$///
app.get(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => {
const file = 'recycler/' + req.params[0]
pagehandler.get(file, (e, page, status) => {
if (e) {
return res.e(e)
}
res.status(status || 200).send(page)
})
})
// Delete page from the recycler
app.delete(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => {
const file = 'recycler/' + req.params[0]
pagehandler.delete(file, err => {
if (err) {
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) {
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) => {
// create a list of plugin pages.
try {
const dependencyPlugins = Object.keys(packageJson.dependencies).filter(depend => depend.startsWith('wiki-plugin'))
const plugins = dependencyPlugins.length
? dependencyPlugins
: Object.keys(packageJson.devDependencies).filter(depend => depend.startsWith('wiki-plugin'))
const pluginNames = plugins.map(name => name.slice(12))
res.send(pluginNames)
} catch (e) {
return res.e(e)
}
// Object.keys(packageJson.dependencies)
// .filter(depend => depend.startsWith('wiki-plugin'))
// .map(name => name.slice(12))
// .then(names => res.send(names))
})
//{
const sitemapLoc = path.join(argv.status, 'sitemap.json')
app.get('/system/sitemap.json', cors, (req, res) => {
fs.access(sitemapLoc, fs.constants.F_OK, err => {
if (!err) {
res.sendFile(sitemapLoc, { dotfiles: 'allow' })
} else {
// only createSitemap if we are not already creating one
if (!sitemaphandler.isWorking()) {
sitemaphandler.createSitemap(pagehandler)
}
// wait for the sitemap file to be written, before sending
sitemaphandler.once('finished', () => {
res.sendFile(sitemapLoc, { dotfiles: 'allow' })
})
}
})
})
const xmlSitemapLoc = path.join(argv.status, 'sitemap.xml')
app.get('/sitemap.xml', cors, (req, res) => {
fs.access(sitemapLoc, fs.constants.F_OK, err => {
if (!err) {
res.sendFile(xmlSitemapLoc, { dotfiles: 'allow' })
} else {
if (!sitemaphandler.isWorking()) {
sitemaphandler.createSitemap(pagehandler)
}
sitemaphandler.once('finished', () => {
res.sendFile(xmlSitemapLoc, { dotfiles: 'allow' })
})
}
})
})
const searchIndexLoc = path.join(argv.status, 'site-index.json')
app.get('/system/site-index.json', cors, (req, res) => {
fs.access(searchIndexLoc, fs.constants.F_OK, err => {
if (!err) {
res.sendFile(searchIndexLoc, { dotfiles: 'allow' })
} else {
// only create index if we are not already creating one
if (!searchhandler.isWorking()) {
searchhandler.createIndex(pagehandler)
}
searchhandler.once('indexed', () => {
res.sendFile(searchIndexLoc, { dotfiles: 'allow' })
})
}
})
})
app.get('/system/export.json', cors, (req, res) => {
pagehandler.pages((e, sitemap) => {
if (e) {
return res.e(e)
}
const pagePromises = sitemap.map(stub => {
return new Promise((resolve, reject) => {
pagehandler.get(stub.slug, (error, page) => {
if (error) {
return reject(error)
}
resolve({ slug: stub.slug, page })
})
})
})
Promise.all(pagePromises)
.then(pages => {
const pageExport = pages.reduce((dict, combined) => {
dict[combined.slug] = combined.page
return dict
}, {})
// TODO: this fails for a very large site
res.json(pageExport)
})
.catch(error => {
res.e(error)
})
})
})
const 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) => {
const getPackageVersion = packageName => {
return new Promise(resolve => {
try {
// Use import to load package.json from the main application's working directory
import(`${packageName}/package.json`, { with: { type: 'json' } }).then(({ default: packageJson }) => {
resolve({ [packageName]: packageJson.version })
})
} catch (error) {
console.error(`Error reading package for ${packageName}:`, error)
resolve({ [packageName]: 'unknown' })
}
})
}
const versions = {}
const security = () => {
return new Promise(resolve => {
Promise.all(
Object.keys(packageJson.dependencies)
.filter(depend => depend.startsWith('wiki-security'))
.map(key => {
return getPackageVersion(key)
}),
).then(values => {
resolve({ security: values.reduce((acc, cV) => Object.assign(acc, cV), {}) })
})
})
}
const plugins = () => {
return new Promise(resolve => {
Promise.all(
Object.keys(packageJson.dependencies)
.filter(depend => depend.startsWith('wiki-plugin'))
.map(key => {
return getPackageVersion(key)
}),
).then(values => {
resolve({ plugins: values.reduce((acc, cV) => Object.assign(acc, cV), {}) })
})
})
}
Promise.all([getPackageVersion('wiki-server'), getPackageVersion('wiki-client'), security(), plugins()]).then(v => {
Object.assign(versions, { [packageJson.name]: packageJson.version }, ...v)
res.json(versions)
})
})
// ##### Proxy routes #####
app.get('/proxy/*splat', authorized, (req, res) => {
const pathParts = req.originalUrl.split('/')
const remoteHost = pathParts[2]
pathParts.splice(0, 3)
const remoteResource = pathParts.join('/')
// this will fail if remote is TLS only!
const requestURL = 'http://' + remoteHost + '/' + remoteResource
console.log('PROXY Request: ', requestURL)
if (
requestURL.endsWith('.json') ||
requestURL.endsWith('.png') ||
requestURL.endsWith('.jpg') ||
pathParts[0] === 'plugin'
) {
fetch(requestURL, { signal: AbortSignal.timeout(2000) })
.then(async fetchRes => {
if (fetchRes.ok) {
res.set('content-type', fetchRes.headers.get('content-type'))
res.set('last-modified', fetchRes.headers.get('last-modified'))
await pipeline(fetchRes.body, res)
} else {
res.status(fetchRes.status).end()
}
})
.catch(err => {
console.log('ERROR: Proxy Request ', requestURL, err)
res.status(500).end()
})
} else {
res.status(400).end()
}
})
// ##### Put routes #####
app.put(/^\/page\/([a-z0-9-]+)\/action$/i, authorized, (req, res) => {
const action = JSON.parse(req.body.action)
// Handle all of the possible actions to be taken on a page,
const actionCB = (e, page, status) => {
//if e then return res.e e
if (status === 404) {
// res.status(status).send(page)
return res.e(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) {
case 'move':
return action.order.map(id => {
const match = page.story.filter(para => id === para.id)[0]
if (!match) throw 'Ignoring move. Try reload.'
return match
})
case 'add': {
const idx = page.story.map(para => para.id).indexOf(action.after) + 1
page.story.splice(idx, 0, action.item)
return page.story
}
case 'remove':
return page.story.filter(para => para?.id !== action.id)
case 'edit':
return page.story.map(para => {
if (para.id === action.id) {
return action.item
} else {
return para
}
})
case 'create':
case 'fork':
return page.story || []
default:
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 (!page.journal) {
page.journal = []
}
if (action.fork) {
page.journal.push({ type: 'fork', site: action.fork, date: action.date - 1 })
delete action.fork
}
page.journal.push(action)
pagehandler.put(req.params[0], page, e => {
if (e) 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)
}
// 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 && err !== 'page does not exist') {
console.log(`Error saving ${req.params[0]} before fork: ${err}`)
}
if (action.forkPage) {
const forkPageCopy = JSON.parse(JSON.stringify(action.forkPage))
delete action.forkPage
actionCB(null, forkPageCopy)
} else {
// Legacy path, new clients will provide forkPage on implicit forks.
remoteGet(action.fork, req.params[0], actionCB)
}
})
} else if (action.type === 'create') {
// Prevent attempt to write circular structure
const itemCopy = JSON.parse(JSON.stringify(action.item))
pagehandler.get(req.params[0], (e, page, status) => {
if (e) return actionCB(e)
if (status !== 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) console.log(`Error saving ${req.params[0]} before fork: ${err}`)
if (action.forkPage) {
// push
const forkPageCopy = JSON.parse(JSON.stringify(action.forkPage))
delete action.forkPage
actionCB(null, forkPageCopy)
} 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('/', cors, (req, res) => {
const 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) => {
const 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) => {
const title = page.title
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])
}
})
})
})
// #### Start the server ####
//
// set a default process exitCode, so we can diferentiate between exiting as part of a reload,
// and an exit after an uncaught error.
// except when test is set, so the tests don't report a fail when closing the server process.
process.exitCode = argv.test ? 0 : 1
// Wait to make sure owner is known before listening.
securityhandler.retrieveOwner(e => {
// Throw if you can't find the initial owner
if (e) throw e
owner = securityhandler.getOwner()
console.log('owner: ' + owner)
app.emit('owner-set')
})
app.on('running-serv', server => {
// ### Plugins ###
// Should replace most WebSocketServers below.
const plugins = pluginsFactory(argv)
plugins.startServers({ argv, app, packageJson })
// ### 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.
return app
}