wiki-server 0.25.9 → 0.26.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORS.txt +1 -0
- package/index.js +3 -7
- package/lib/defaultargs.js +83 -0
- package/lib/forward.js +10 -10
- package/lib/page.js +448 -0
- package/lib/plugins.js +54 -0
- package/lib/random_id.js +15 -0
- package/lib/search.js +390 -0
- package/lib/security.js +94 -0
- package/lib/server.js +942 -0
- package/lib/sitemap.js +288 -0
- package/package.json +32 -23
- package/.github/workflows/node.js.yml +0 -31
- package/.mailmap +0 -1
- package/Gruntfile.js +0 -55
- package/lib/defaultargs.coffee +0 -77
- package/lib/page.coffee +0 -371
- package/lib/plugins.coffee +0 -48
- package/lib/random_id.coffee +0 -18
- package/lib/search.coffee +0 -304
- package/lib/security.coffee +0 -86
- package/lib/server.coffee +0 -782
- package/lib/sitemap.coffee +0 -246
- package/test/asdf-test-page +0 -8
- package/test/defaultargs.coffee +0 -10
- package/test/mocha.opts +0 -2
- package/test/page.coffee +0 -61
- package/test/random.coffee +0 -8
- package/test/server.coffee +0 -228
- package/test/sitemap.coffee +0 -110
package/lib/server.js
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Federated Wiki : Node Server
|
|
3
|
+
*
|
|
4
|
+
* Copyright Ward Cunningham and other contributors
|
|
5
|
+
* Licensed under the MIT license.
|
|
6
|
+
* https://github.com/fedwiki/wiki-server/blob/master/LICENSE.txt
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// **server.coffee** is the main guts of the express version
|
|
10
|
+
// of (Smallest Federated Wiki)[https://github.com/WardCunningham/Smallest-Federated-Wiki].
|
|
11
|
+
// The CLI and Farm are just front ends
|
|
12
|
+
// for setting arguments, and spawning servers. In a complex system
|
|
13
|
+
// you would probably want to replace the CLI/Farm with your own code,
|
|
14
|
+
// and use server.coffee directly.
|
|
15
|
+
//
|
|
16
|
+
// #### Dependencies ####
|
|
17
|
+
// anything not in the standard library is included in the repo, or
|
|
18
|
+
// can be installed with an:
|
|
19
|
+
// npm install
|
|
20
|
+
|
|
21
|
+
// Standard lib
|
|
22
|
+
const fs = require('fs')
|
|
23
|
+
const path = require('path')
|
|
24
|
+
const http = require('http')
|
|
25
|
+
const url = require('url')
|
|
26
|
+
const { pipeline } = require('node:stream/promises')
|
|
27
|
+
|
|
28
|
+
// From npm
|
|
29
|
+
const express = require('express')
|
|
30
|
+
const hbs = require('express-hbs')
|
|
31
|
+
const glob = require('glob')
|
|
32
|
+
const async = require('async')
|
|
33
|
+
const f = require('flates')
|
|
34
|
+
|
|
35
|
+
const createDOMPurify = require('dompurify')
|
|
36
|
+
const { JSDOM } = require('jsdom')
|
|
37
|
+
|
|
38
|
+
const window = new JSDOM('').window
|
|
39
|
+
const DOMPurify = createDOMPurify(window)
|
|
40
|
+
|
|
41
|
+
// node-fetch is now ESM only
|
|
42
|
+
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args))
|
|
43
|
+
|
|
44
|
+
// Express 4 middleware
|
|
45
|
+
const logger = require('morgan')
|
|
46
|
+
const cookieParser = require('cookie-parser')
|
|
47
|
+
const methodOverride = require('method-override')
|
|
48
|
+
// session = require 'express-session'
|
|
49
|
+
const sessions = require('client-sessions')
|
|
50
|
+
const bodyParser = require('body-parser')
|
|
51
|
+
const errorHandler = require('errorhandler')
|
|
52
|
+
|
|
53
|
+
// Local files
|
|
54
|
+
const random = require('./random_id')
|
|
55
|
+
const defargs = require('./defaultargs')
|
|
56
|
+
const resolveClient = require('wiki-client/lib/resolve')
|
|
57
|
+
const pluginsFactory = require('./plugins')
|
|
58
|
+
const sitemapFactory = require('./sitemap')
|
|
59
|
+
const searchFactory = require('./search')
|
|
60
|
+
|
|
61
|
+
const render = page => {
|
|
62
|
+
return (
|
|
63
|
+
f.div({ class: 'twins' }, f.p('')) +
|
|
64
|
+
'\n' +
|
|
65
|
+
f.div(
|
|
66
|
+
{ class: 'header' },
|
|
67
|
+
f.h1(
|
|
68
|
+
f.a({ href: '/', style: 'text-decoration: none' }, f.img({ height: '32px', src: '/favicon.png' })) +
|
|
69
|
+
' ' +
|
|
70
|
+
page.title,
|
|
71
|
+
),
|
|
72
|
+
) +
|
|
73
|
+
'\n' +
|
|
74
|
+
f.div(
|
|
75
|
+
{ class: 'story' },
|
|
76
|
+
page.story
|
|
77
|
+
.map(story => {
|
|
78
|
+
if (!story) return ''
|
|
79
|
+
if (story.type === 'paragraph') {
|
|
80
|
+
f.div({ class: 'item paragraph' }, f.p(resolveClient.resolveLinks(story.text)))
|
|
81
|
+
} else if (story.type === 'image') {
|
|
82
|
+
f.div(
|
|
83
|
+
{ class: 'item image' },
|
|
84
|
+
f.img({ class: 'thumbnail', src: story.url }),
|
|
85
|
+
f.p(resolveClient.resolveLinks(story.text || story.caption || 'uploaded image')),
|
|
86
|
+
)
|
|
87
|
+
} else if (story.type === 'html') {
|
|
88
|
+
f.div({ class: 'item html' }, f.p(resolveClient.resolveLinks(story.text || '', DOMPurify.sanitize)))
|
|
89
|
+
} else f.div({ class: 'item' }, f.p(resolveClient.resolveLinks(story.text || '')))
|
|
90
|
+
})
|
|
91
|
+
.join('\n'),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
// Set export objects for node and coffee to a function that generates a sfw server.
|
|
96
|
+
module.exports = exports = argv => {
|
|
97
|
+
// Create the main application object, app.
|
|
98
|
+
const app = express()
|
|
99
|
+
|
|
100
|
+
// remove x-powered-by header
|
|
101
|
+
app.disable('x-powered-by')
|
|
102
|
+
|
|
103
|
+
// defaultargs.coffee exports a function that takes the argv object
|
|
104
|
+
// that is passed in and then does its
|
|
105
|
+
// best to supply sane defaults for any arguments that are missing.
|
|
106
|
+
argv = defargs(argv)
|
|
107
|
+
|
|
108
|
+
app.startOpts = argv
|
|
109
|
+
|
|
110
|
+
const log = (...stuff) => {
|
|
111
|
+
if (argv.debug) console.log(stuff)
|
|
112
|
+
}
|
|
113
|
+
const loga = (...stuff) => {
|
|
114
|
+
console.log(stuff)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ourErrorHandler = (req, res, next) => {
|
|
118
|
+
let fired = false
|
|
119
|
+
res.e = (error, status) => {
|
|
120
|
+
if (!fired) {
|
|
121
|
+
fired = true
|
|
122
|
+
res.statusCode = status || 500
|
|
123
|
+
res.end('Server ' + error)
|
|
124
|
+
log('Res sent:', res.statusCode, error)
|
|
125
|
+
} else {
|
|
126
|
+
log('Already fired', error)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
next()
|
|
130
|
+
}
|
|
131
|
+
let pagehandler, sitemaphandler, searchhandler, securityhandler
|
|
132
|
+
// Require the database adapter and initialize it with options.
|
|
133
|
+
app.pagehandler = pagehandler = require(argv.database.type)(argv)
|
|
134
|
+
|
|
135
|
+
// Require the sitemap adapter and initialize it with options.
|
|
136
|
+
app.sitemaphandler = sitemaphandler = sitemapFactory(argv)
|
|
137
|
+
|
|
138
|
+
// Require the site indexer and initialize it with options
|
|
139
|
+
app.searchhandler = searchhandler = searchFactory(argv)
|
|
140
|
+
|
|
141
|
+
// Require the security adapter and initialize it with options.
|
|
142
|
+
app.securityhandler = securityhandler = require(argv.security_type)(log, loga, argv)
|
|
143
|
+
|
|
144
|
+
// If the site is owned, owner will contain the name of the owner
|
|
145
|
+
let owner = ''
|
|
146
|
+
|
|
147
|
+
// If the user is logged in, user will contain their identity
|
|
148
|
+
let user = ''
|
|
149
|
+
|
|
150
|
+
// Called from authentication when the site is claimed,
|
|
151
|
+
// to update the name of the owner held here.
|
|
152
|
+
const updateOwner = id => {
|
|
153
|
+
owner = id
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// #### Middleware ####
|
|
157
|
+
//
|
|
158
|
+
// Allow json to be got cross origin.
|
|
159
|
+
const cors = (req, res, next) => {
|
|
160
|
+
res.header('Access-Control-Allow-Origin', req.get('origin') || '*')
|
|
161
|
+
next()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const remoteGet = (remote, slug, cb) => {
|
|
165
|
+
// assume http, as we know no better at this point and we need to specify a protocol.
|
|
166
|
+
const remoteURL = new URL('http://#{remote}/#{slug}.json').toString()
|
|
167
|
+
// set a two second timeout
|
|
168
|
+
fetch(remoteURL, { signal: AbortSignal.timeout(2000) })
|
|
169
|
+
.then(res => {
|
|
170
|
+
if (res.ok) {
|
|
171
|
+
return res
|
|
172
|
+
}
|
|
173
|
+
throw new Error(res.statusText)
|
|
174
|
+
})
|
|
175
|
+
.then(res => {
|
|
176
|
+
return res.json()
|
|
177
|
+
})
|
|
178
|
+
.then(json => {
|
|
179
|
+
cb(null, json, 200)
|
|
180
|
+
})
|
|
181
|
+
.catch(err => {
|
|
182
|
+
console.error('Unable to fetch remote resource', remote, slug, err)
|
|
183
|
+
cb(err, 'Page not found', 404)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// #### Express configuration ####
|
|
188
|
+
// Set up all the standard express server options,
|
|
189
|
+
// including hbs to use handlebars/mustache templates
|
|
190
|
+
// saved with a .html extension, and no layout.
|
|
191
|
+
|
|
192
|
+
//
|
|
193
|
+
const staticPathOptions = {
|
|
194
|
+
dotfiles: 'ignore',
|
|
195
|
+
etag: true,
|
|
196
|
+
immutable: false,
|
|
197
|
+
lastModified: false,
|
|
198
|
+
maxAge: '1h',
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
app.set('views', path.join(require.resolve('wiki-client/package.json'), '..', 'views'))
|
|
202
|
+
app.set('view engine', 'html')
|
|
203
|
+
app.engine('html', hbs.express4())
|
|
204
|
+
app.set('view options', { layout: false })
|
|
205
|
+
|
|
206
|
+
// return deterministically colored strings
|
|
207
|
+
const colorString = str => {
|
|
208
|
+
const colorReset = '\x1b[0m'
|
|
209
|
+
let hash = 0
|
|
210
|
+
str.split('').forEach(char => {
|
|
211
|
+
hash = char.charCodeAt(0) + ((hash << 5) - hash)
|
|
212
|
+
})
|
|
213
|
+
let color = '\x1b[38;2'
|
|
214
|
+
;[...Array(3).keys()].forEach(i => {
|
|
215
|
+
const value = (hash >> (i * 8)) & 0xff
|
|
216
|
+
color += ':' + value.toString()
|
|
217
|
+
})
|
|
218
|
+
color += 'm'
|
|
219
|
+
return color + str + colorReset
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// use logger, at least in development, probably needs a param to configure (or turn off).
|
|
223
|
+
// use stream to direct to somewhere other than stdout.
|
|
224
|
+
logger.token('vhost', (req, res) => {
|
|
225
|
+
return colorString(req.hostname)
|
|
226
|
+
})
|
|
227
|
+
app.use(logger(':vhost :method :url :status :res[content-length] - :response-time ms'))
|
|
228
|
+
app.use(cookieParser())
|
|
229
|
+
app.use(bodyParser.json({ limit: argv.uploadLimit }))
|
|
230
|
+
app.use(bodyParser.urlencoded({ extended: true, limit: argv.uploadLimit }))
|
|
231
|
+
app.use(methodOverride())
|
|
232
|
+
const cookieValue = {
|
|
233
|
+
httpOnly: true,
|
|
234
|
+
sameSite: 'lax',
|
|
235
|
+
}
|
|
236
|
+
if (argv.wiki_domain) {
|
|
237
|
+
if (!argv.wiki_domain.endsWith('localhost')) {
|
|
238
|
+
cookieValue['domain'] = argv.wiki_domain
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// use secureProxy as TLS is terminated in outside the node process
|
|
242
|
+
let cookieName
|
|
243
|
+
if (argv.secure_cookie) {
|
|
244
|
+
cookieName = 'wikiTlsSession'
|
|
245
|
+
cookieValue['secureProxy'] = true
|
|
246
|
+
} else {
|
|
247
|
+
cookieName = 'wikiSession'
|
|
248
|
+
}
|
|
249
|
+
app.use(
|
|
250
|
+
sessions({
|
|
251
|
+
cookieName: cookieName,
|
|
252
|
+
requestKey: 'session',
|
|
253
|
+
secret: argv.cookieSecret,
|
|
254
|
+
// make the session session_duration days long
|
|
255
|
+
duration: argv.session_duration * 24 * 60 * 60 * 1000,
|
|
256
|
+
// add 12 hours to session if less than 12 hours to expiry
|
|
257
|
+
activeDuration: 24 * 60 * 60 * 1000,
|
|
258
|
+
cookie: cookieValue,
|
|
259
|
+
}),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
app.use(ourErrorHandler)
|
|
263
|
+
|
|
264
|
+
// Add static route to the client
|
|
265
|
+
app.use(express.static(argv.client, staticPathOptions))
|
|
266
|
+
|
|
267
|
+
// ##### Define security routes #####
|
|
268
|
+
securityhandler.defineRoutes(app, cors, updateOwner)
|
|
269
|
+
|
|
270
|
+
// Add static route to assets
|
|
271
|
+
app.use('/assets', cors, express.static(argv.assets))
|
|
272
|
+
|
|
273
|
+
// Add static routes to the plugins client.
|
|
274
|
+
glob('wiki-plugin-*/client', { cwd: argv.packageDir }, (e, plugins) => {
|
|
275
|
+
plugins.map(plugin => {
|
|
276
|
+
const pluginName = plugin.slice(12, -7)
|
|
277
|
+
const pluginPath = '/plugins/' + pluginName
|
|
278
|
+
app.use(pluginPath, cors, express.static(path.join(argv.packageDir, plugin), staticPathOptions))
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// Add static routes to the security client.
|
|
283
|
+
if (argv.security != './security') {
|
|
284
|
+
app.use('/security', express.static(path.join(argv.packageDir, argv.security_type, 'client'), staticPathOptions))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ##### Set up standard environments. #####
|
|
288
|
+
// In dev mode turn on console.log debugging as well as showing the stack on err.
|
|
289
|
+
if ('development' == app.get('env')) {
|
|
290
|
+
app.use(errorHandler())
|
|
291
|
+
argv.debug = true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Show all of the options a server is using.
|
|
295
|
+
log(argv)
|
|
296
|
+
|
|
297
|
+
// #### Routes ####
|
|
298
|
+
// Routes currently make up the bulk of the Express port of
|
|
299
|
+
// Smallest Federated Wiki. Most routes use literal names,
|
|
300
|
+
// or regexes to match, and then access req.params directly.
|
|
301
|
+
|
|
302
|
+
// ##### Redirects #####
|
|
303
|
+
// Common redirects that may get used throughout the routes.
|
|
304
|
+
const index = argv.home + '.html'
|
|
305
|
+
const oops = '/oops'
|
|
306
|
+
|
|
307
|
+
// ##### Get routes #####
|
|
308
|
+
// Routes have mostly been kept together by http verb, with the exception
|
|
309
|
+
// of the openID related routes which are at the end together.
|
|
310
|
+
|
|
311
|
+
// Main route for initial contact. Allows us to
|
|
312
|
+
// link into a specific set of pages, local and remote.
|
|
313
|
+
// Can also be handled by the client, but it also sets up
|
|
314
|
+
// the login status, and related footer html, which the client
|
|
315
|
+
// relies on to know if it is logged in or not.
|
|
316
|
+
app.get(/^((\/[a-zA-Z0-9:.-]+\/[a-z0-9-]+(_rev\d+)?)+)\/?$/, cors, (req, res, next) => {
|
|
317
|
+
const urlPages = req.params[0]
|
|
318
|
+
.split('/')
|
|
319
|
+
.filter((_, index) => index % 2 === 0)
|
|
320
|
+
.slice(1)
|
|
321
|
+
const urlLocs = req.params[0]
|
|
322
|
+
.split('/')
|
|
323
|
+
.slice(1)
|
|
324
|
+
.filter((_, index) => index % 2 === 0)
|
|
325
|
+
if (['plugin', 'auth'].indexOf(urlLocs[0]) > -1) {
|
|
326
|
+
return next()
|
|
327
|
+
}
|
|
328
|
+
const title = urlPages.slice().pop().replace(/-+/g, ' ')
|
|
329
|
+
user = securityhandler.getUser(req)
|
|
330
|
+
const info = {
|
|
331
|
+
title,
|
|
332
|
+
pages: [],
|
|
333
|
+
authenticated: user ? true : false,
|
|
334
|
+
user: user,
|
|
335
|
+
seedNeighbors: argv.neighbors,
|
|
336
|
+
owned: owner ? true : false,
|
|
337
|
+
isOwner: securityhandler.isAuthorized(req) ? true : false,
|
|
338
|
+
ownedBy: owner ? owner : '',
|
|
339
|
+
}
|
|
340
|
+
for (const [idx, page] of urlPages.entries()) {
|
|
341
|
+
let pageDiv
|
|
342
|
+
if (urlLocs[idx] === 'view') {
|
|
343
|
+
pageDiv = { page }
|
|
344
|
+
} else {
|
|
345
|
+
pageDiv = { page, origin: `data-site=${urlLocs[idx]}` }
|
|
346
|
+
}
|
|
347
|
+
info.pages.push(pageDiv)
|
|
348
|
+
}
|
|
349
|
+
res.render('static.html', info)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
app.get(/^\/([a-z0-9-]+)\.html$/, cors, (req, res, next) => {
|
|
353
|
+
const slug = req.params[0]
|
|
354
|
+
log(slug)
|
|
355
|
+
if (slug === 'runtests') return next()
|
|
356
|
+
pagehandler.get(slug, (e, page, status) => {
|
|
357
|
+
if (e) {
|
|
358
|
+
return res.e(e)
|
|
359
|
+
}
|
|
360
|
+
if (status === 404) {
|
|
361
|
+
return res.status(status).send(page)
|
|
362
|
+
}
|
|
363
|
+
page.title ||= slug.replace(/-+/g, ' ')
|
|
364
|
+
page.story ||= []
|
|
365
|
+
user = securityhandler.getUser(req)
|
|
366
|
+
|
|
367
|
+
const info = {
|
|
368
|
+
title: page.title,
|
|
369
|
+
pages: [
|
|
370
|
+
{
|
|
371
|
+
page: slug,
|
|
372
|
+
generated: 'data-server-generated=true',
|
|
373
|
+
story: render(page),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
authenticated: user ? true : false,
|
|
377
|
+
user: user,
|
|
378
|
+
seedNeighbors: argv.neighbors,
|
|
379
|
+
owned: owner ? true : false,
|
|
380
|
+
isOwner: securityhandler.isAuthorized(req) ? true : false,
|
|
381
|
+
ownedBy: owner ? owner : '',
|
|
382
|
+
}
|
|
383
|
+
res.render('static.html', info)
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
app.get('/system/factories.json', (req, res) => {
|
|
388
|
+
res.status(200)
|
|
389
|
+
res.header('Content-Type', 'application/json')
|
|
390
|
+
// Plugins are located in packages in argv.packageDir, with package names of the form wiki-plugin-*
|
|
391
|
+
glob(path.join(argv.packageDir, 'wiki-plugin-*', 'factory.json'), (e, files) => {
|
|
392
|
+
if (e) {
|
|
393
|
+
return res.e(e)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const doFactories = (file, cb) => {
|
|
397
|
+
fs.readFile(file, (err, data) => {
|
|
398
|
+
if (err) {
|
|
399
|
+
return cb()
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const factory = JSON.parse(data)
|
|
403
|
+
cb(null, factory)
|
|
404
|
+
} catch (err) {
|
|
405
|
+
return cb()
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
async.map(files, doFactories, (e, factories) => {
|
|
410
|
+
if (e) {
|
|
411
|
+
res.e(e)
|
|
412
|
+
}
|
|
413
|
+
res.end(JSON.stringify(factories))
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// ###### Json Routes ######
|
|
419
|
+
// Handle fetching local and remote json pages.
|
|
420
|
+
// Local pages are handled by the pagehandler module.
|
|
421
|
+
app.get(/^\/([a-z0-9-]+)\.json$/, cors, (req, res) => {
|
|
422
|
+
const file = req.params[0]
|
|
423
|
+
pagehandler.get(file, (e, page, status) => {
|
|
424
|
+
if (e) {
|
|
425
|
+
return res.e(e)
|
|
426
|
+
}
|
|
427
|
+
res.status(status || 200).send(page)
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// Remote pages use the http client to retrieve the page
|
|
432
|
+
// and sends it to the client. TODO: consider caching remote pages locally.
|
|
433
|
+
app.get(/^\/remote\/([a-zA-Z0-9:.-]+)\/([a-z0-9-]+)\.json$/, (req, res) => {
|
|
434
|
+
remoteGet(req.params[0], req.params[1], (e, page, status) => {
|
|
435
|
+
if (e) {
|
|
436
|
+
log('remoteGet error:', e)
|
|
437
|
+
return res.e(e)
|
|
438
|
+
}
|
|
439
|
+
res.status(status || 200).send(page)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// ###### Theme Routes ######
|
|
444
|
+
// If themes doesn't exist send 404 and let the client
|
|
445
|
+
// deal with it.
|
|
446
|
+
app.get(/^\/theme\/(\w+\.\w+)$/, cors, (req, res) => {
|
|
447
|
+
res.sendFile(path.join(argv.status, 'theme', req.params[0]), e => {
|
|
448
|
+
if (e) {
|
|
449
|
+
// swallow the error if the theme does not exist...
|
|
450
|
+
if (req.path === '/theme/style.css') {
|
|
451
|
+
res.set('Content-Type', 'text/css')
|
|
452
|
+
res.send('')
|
|
453
|
+
} else {
|
|
454
|
+
res.sendStatus(404)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ###### Favicon Routes ######
|
|
461
|
+
// If favLoc doesn't exist send the default favicon.
|
|
462
|
+
const favLoc = path.join(argv.status, 'favicon.png')
|
|
463
|
+
const defaultFavLoc = path.join(argv.root, 'default-data', 'status', 'favicon.png')
|
|
464
|
+
app.get('/favicon.png', cors, (req, res) => {
|
|
465
|
+
fs.exists(favLoc, exists => {
|
|
466
|
+
if (exists) {
|
|
467
|
+
res.sendFile(favLoc)
|
|
468
|
+
} else {
|
|
469
|
+
res.sendFile(defaultFavLoc)
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const authorized = (req, res, next) => {
|
|
475
|
+
if (securityhandler.isAuthorized(req)) {
|
|
476
|
+
next()
|
|
477
|
+
} else {
|
|
478
|
+
console.log('rejecting', req.path)
|
|
479
|
+
res.sendStatus(403)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Accept favicon image posted to the server, and if it does not already exist
|
|
484
|
+
// save it.
|
|
485
|
+
app.post('/favicon.png', authorized, (req, res) => {
|
|
486
|
+
const favicon = req.body.image.replace(/^data:image\/png;base64,/, '')
|
|
487
|
+
const buf = new Buffer(favicon, 'base64')
|
|
488
|
+
fs.exists(argv.status, exists => {
|
|
489
|
+
if (exists) {
|
|
490
|
+
fs.writeFile(favLoc, buf, e => {
|
|
491
|
+
if (e) {
|
|
492
|
+
return res.e(e)
|
|
493
|
+
}
|
|
494
|
+
res.send('Favicon Saved')
|
|
495
|
+
})
|
|
496
|
+
} else {
|
|
497
|
+
fs.mkdir(argv.status, { recursive: true }, () => {
|
|
498
|
+
fs.writeFile(favLoc, buf, e => {
|
|
499
|
+
if (e) {
|
|
500
|
+
return res.e(e)
|
|
501
|
+
}
|
|
502
|
+
res.send('Favicon Saved')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// Redirect remote favicons to the server they are needed from.
|
|
510
|
+
app.get(/^\/remote\/([a-zA-Z0-9:.-]+\/favicon.png)$/, (req, res) => {
|
|
511
|
+
const remotefav = 'http://#{req.params[0]}'
|
|
512
|
+
res.redirect(remotefav)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// ###### Recycler Routes ######
|
|
516
|
+
// These routes are only available to the site's owner
|
|
517
|
+
|
|
518
|
+
// Give the recycler a standard flag - use the Taiwan symbol as the use of
|
|
519
|
+
// negative space outward pointing arrows nicely indicates that items can be removed
|
|
520
|
+
const recyclerFavLoc = path.join(argv.root, 'default-data', 'status', 'recycler.png')
|
|
521
|
+
app.get('/recycler/favicon.png', authorized, (req, res) => {
|
|
522
|
+
res.sendFile(recyclerFavLoc)
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
// Send an array of pages currently in the recycler via json
|
|
526
|
+
app.get('/recycler/system/slugs.json', authorized, (req, res) => {
|
|
527
|
+
fs.readdir(argv.recycler, (e, files) => {
|
|
528
|
+
const doRecyclermap = (file, cb) => {
|
|
529
|
+
const recycleFile = 'recycler/' + file
|
|
530
|
+
pagehandler.get(recycleFile, (e, page, status) => {
|
|
531
|
+
if (e || status === 404) {
|
|
532
|
+
console.log('Problem building recycler map:', file, 'e: ', e)
|
|
533
|
+
// this will leave an undefined/empty item in the array, which we will filter out later
|
|
534
|
+
return cb()
|
|
535
|
+
}
|
|
536
|
+
cb(null, {
|
|
537
|
+
slug: file,
|
|
538
|
+
title: page.title,
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (e) {
|
|
544
|
+
return res.e(e)
|
|
545
|
+
}
|
|
546
|
+
async.map(files, doRecyclermap, (e, recyclermap) => {
|
|
547
|
+
if (e) {
|
|
548
|
+
return res.e(e)
|
|
549
|
+
}
|
|
550
|
+
// remove any empty items
|
|
551
|
+
recyclermap = recyclermap.filter(el => !!el)
|
|
552
|
+
res.send(recyclermap)
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// Fetching page from the recycler
|
|
558
|
+
/////^/([a-z0-9-]+)\.json$///
|
|
559
|
+
app.get(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => {
|
|
560
|
+
const file = 'recycler/' + req.params[0]
|
|
561
|
+
pagehandler.get(file, (e, page, status) => {
|
|
562
|
+
if (e) {
|
|
563
|
+
return res.e(e)
|
|
564
|
+
}
|
|
565
|
+
res.status(status || 200).send(page)
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// Delete page from the recycler
|
|
570
|
+
app.delete(/^\/recycler\/([a-z0-9-]+)\.json$/, authorized, (req, res) => {
|
|
571
|
+
const file = 'recycler/' + req.params[0]
|
|
572
|
+
pagehandler.delete(file, err => {
|
|
573
|
+
if (err) {
|
|
574
|
+
res.status(500).send(err)
|
|
575
|
+
}
|
|
576
|
+
res.status(200).send('')
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
// ###### Meta Routes ######
|
|
581
|
+
// Send an array of pages in the database via json
|
|
582
|
+
app.get('/system/slugs.json', cors, (req, res) => {
|
|
583
|
+
pagehandler.slugs((err, files) => {
|
|
584
|
+
if (err) {
|
|
585
|
+
res.status(500).send(err)
|
|
586
|
+
}
|
|
587
|
+
res.send(files)
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
// Returns a list of installed plugins. (does this get called anymore!)
|
|
592
|
+
app.get('/system/plugins.json', cors, (req, res) => {
|
|
593
|
+
glob('wiki-plugin-*', { cwd: argv.packageDir }, (e, files) => {
|
|
594
|
+
if (e) {
|
|
595
|
+
return res.e(e)
|
|
596
|
+
}
|
|
597
|
+
// extract the plugin name from the name of the directory it's installed in
|
|
598
|
+
files = files.map(file => file.slice(12))
|
|
599
|
+
res.send(files)
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
//{
|
|
603
|
+
const sitemapLoc = path.join(argv.status, 'sitemap.json')
|
|
604
|
+
app.get('/system/sitemap.json', cors, (req, res) => {
|
|
605
|
+
fs.exists(sitemapLoc, exists => {
|
|
606
|
+
if (exists) {
|
|
607
|
+
res.sendFile(sitemapLoc)
|
|
608
|
+
} else {
|
|
609
|
+
// only createSitemap if we are not already creating one
|
|
610
|
+
if (!sitemaphandler.isWorking()) {
|
|
611
|
+
sitemaphandler.createSitemap(pagehandler)
|
|
612
|
+
}
|
|
613
|
+
// wait for the sitemap file to be written, before sending
|
|
614
|
+
sitemaphandler.once('finished', () => {
|
|
615
|
+
res.sendFile(sitemapLoc)
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
const xmlSitemapLoc = path.join(argv.status, 'sitemap.xml')
|
|
622
|
+
app.get('/sitemap.xml', cors, (req, res) => {
|
|
623
|
+
fs.exists(sitemapLoc, exists => {
|
|
624
|
+
if (exists) {
|
|
625
|
+
res.sendFile(xmlSitemapLoc)
|
|
626
|
+
} else {
|
|
627
|
+
if (!sitemaphandler.isWorking()) {
|
|
628
|
+
sitemaphandler.createSitemap(pagehandler)
|
|
629
|
+
}
|
|
630
|
+
sitemaphandler.once('finished', () => {
|
|
631
|
+
res.sendFile(xmlSitemapLoc)
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
const searchIndexLoc = path.join(argv.status, 'site-index.json')
|
|
638
|
+
app.get('/system/site-index.json', cors, (req, res) => {
|
|
639
|
+
fs.exists(searchIndexLoc, exists => {
|
|
640
|
+
if (exists) {
|
|
641
|
+
res.sendFile(searchIndexLoc)
|
|
642
|
+
} else {
|
|
643
|
+
// only create index if we are not already creating one
|
|
644
|
+
if (!searchhandler.isWorking()) {
|
|
645
|
+
searchhandler.createIndex(pagehandler)
|
|
646
|
+
}
|
|
647
|
+
searchhandler.once('indexed', () => {
|
|
648
|
+
res.sendFile(searchIndexLoc)
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
app.get('/system/export.json', cors, (req, res) => {
|
|
655
|
+
pagehandler.pages((e, sitemap) => {
|
|
656
|
+
if (e) {
|
|
657
|
+
return res.e(e)
|
|
658
|
+
}
|
|
659
|
+
async.map(
|
|
660
|
+
sitemap,
|
|
661
|
+
(stub, done) => {
|
|
662
|
+
pagehandler.get(stub.slug, (error, page) => {
|
|
663
|
+
if (e) {
|
|
664
|
+
return done(e)
|
|
665
|
+
}
|
|
666
|
+
done(null, { slug: stub.slug, page })
|
|
667
|
+
})
|
|
668
|
+
},
|
|
669
|
+
(e, pages) => {
|
|
670
|
+
if (e) {
|
|
671
|
+
return res.e(e)
|
|
672
|
+
}
|
|
673
|
+
res.json(
|
|
674
|
+
pages.reduce((dict, combined) => {
|
|
675
|
+
dict[combined.slug] = combined.page
|
|
676
|
+
return dict
|
|
677
|
+
}, {}),
|
|
678
|
+
)
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
const admin = (req, res, next) => {
|
|
685
|
+
if (securityhandler.isAdmin(req)) {
|
|
686
|
+
next()
|
|
687
|
+
} else {
|
|
688
|
+
console.log('rejecting', req.path)
|
|
689
|
+
res.sendStatus(403)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
app.get('/system/version.json', admin, (req, res) => {
|
|
694
|
+
const versions = {}
|
|
695
|
+
const wikiModule = module.parent.parent.parent
|
|
696
|
+
versions[wikiModule.require('./package').name] = wikiModule.require('./package').version
|
|
697
|
+
versions[wikiModule.require('wiki-server/package').name] = wikiModule.require('wiki-server/package').version
|
|
698
|
+
versions[wikiModule.require('wiki-client/package').name] = wikiModule.require('wiki-client/package').version
|
|
699
|
+
versions['security'] = {}
|
|
700
|
+
versions['plugins'] = {}
|
|
701
|
+
|
|
702
|
+
glob('+(wiki-security-*|wiki-plugin-*)', { cwd: argv.packageDir }, (e, plugins) => {
|
|
703
|
+
plugins.map(plugin => {
|
|
704
|
+
if (plugin.includes('wiki-security')) {
|
|
705
|
+
versions.security[wikiModule.require(plugin + '/package').name] = wikiModule.require(
|
|
706
|
+
plugin + '/package',
|
|
707
|
+
).version
|
|
708
|
+
} else {
|
|
709
|
+
versions.plugins[wikiModule.require(plugin + '/package').name] = wikiModule.require(
|
|
710
|
+
plugin + '/package',
|
|
711
|
+
).version
|
|
712
|
+
}
|
|
713
|
+
res.json(versions)
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
// ##### Proxy routes #####
|
|
719
|
+
|
|
720
|
+
app.get('/proxy/*', authorized, (req, res) => {
|
|
721
|
+
const pathParts = req.originalUrl.split('/')
|
|
722
|
+
const remoteHost = pathParts[2]
|
|
723
|
+
pathParts.splice(0, 3)
|
|
724
|
+
const remoteResource = pathParts.join('/')
|
|
725
|
+
// this will fail if remote is TLS only!
|
|
726
|
+
const requestURL = 'http://' + remoteHost + '/' + remoteResource
|
|
727
|
+
console.log('PROXY Request: ', requestURL)
|
|
728
|
+
if (
|
|
729
|
+
requestURL.endsWith('.json') ||
|
|
730
|
+
requestURL.endsWith('.png') ||
|
|
731
|
+
requestURL.endsWith('.jpg') ||
|
|
732
|
+
pathParts[0] === 'plugin'
|
|
733
|
+
) {
|
|
734
|
+
fetch(requestURL, { timeout: 2000 })
|
|
735
|
+
.then(async fetchRes => {
|
|
736
|
+
if (fetchRes.ok) {
|
|
737
|
+
res.set('content-type', fetchRes.headers.get('content-type'))
|
|
738
|
+
res.set('last-modified', fetchRes.headers.get('last-modified'))
|
|
739
|
+
await pipeline(fetchRes.body, res)
|
|
740
|
+
} else {
|
|
741
|
+
res.status(fetchRes.status).end()
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
.catch(err => {
|
|
745
|
+
console.log('ERROR: Proxy Request ', requestURL, err)
|
|
746
|
+
res.status(500).end()
|
|
747
|
+
})
|
|
748
|
+
} else {
|
|
749
|
+
res.status(400).end()
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
// ##### Put routes #####
|
|
754
|
+
|
|
755
|
+
app.put(/^\/page\/([a-z0-9-]+)\/action$/i, authorized, (req, res) => {
|
|
756
|
+
const action = JSON.parse(req.body.action)
|
|
757
|
+
// Handle all of the possible actions to be taken on a page,
|
|
758
|
+
const actionCB = (e, page, status) => {
|
|
759
|
+
//if e then return res.e e
|
|
760
|
+
if (status === 404) {
|
|
761
|
+
// res.status(status).send(page)
|
|
762
|
+
return res.e(page, status)
|
|
763
|
+
}
|
|
764
|
+
// Using Coffee-Scripts implicit returns we assign page.story to the
|
|
765
|
+
// result of a list comprehension by way of a switch expression.
|
|
766
|
+
try {
|
|
767
|
+
page.story = (() => {
|
|
768
|
+
switch (action.type) {
|
|
769
|
+
case 'move':
|
|
770
|
+
return action.order.map(id => {
|
|
771
|
+
const match = page.story.filter(para => id === para.id)[0]
|
|
772
|
+
if (!match) throw 'Ignoring move. Try reload.'
|
|
773
|
+
return match
|
|
774
|
+
})
|
|
775
|
+
case 'add': {
|
|
776
|
+
const idx = page.story.map(para => para.id).indexOf(action.after) + 1
|
|
777
|
+
page.story.splice(idx, 0, action.item)
|
|
778
|
+
return page.story
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
case 'remove':
|
|
782
|
+
return page.story.filter(para => para?.id !== action.id)
|
|
783
|
+
|
|
784
|
+
case 'edit':
|
|
785
|
+
return page.story.map(para => {
|
|
786
|
+
if (para.id === action.id) {
|
|
787
|
+
return action.item
|
|
788
|
+
} else {
|
|
789
|
+
return para
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
case 'create':
|
|
794
|
+
case 'fork':
|
|
795
|
+
return page.story || []
|
|
796
|
+
|
|
797
|
+
default:
|
|
798
|
+
log('Unfamiliar action:', action)
|
|
799
|
+
//page.story
|
|
800
|
+
throw 'Unfamiliar action ignored'
|
|
801
|
+
}
|
|
802
|
+
})()
|
|
803
|
+
} catch (e) {
|
|
804
|
+
return res.e(e)
|
|
805
|
+
}
|
|
806
|
+
// Add a blank journal if it does not exist.
|
|
807
|
+
// And add what happened to the journal.
|
|
808
|
+
if (!page.journal) {
|
|
809
|
+
page.journal = []
|
|
810
|
+
}
|
|
811
|
+
if (action.fork) {
|
|
812
|
+
page.journal.push({ type: 'fork', site: action.fork, date: action.date - 1 })
|
|
813
|
+
delete action.fork
|
|
814
|
+
}
|
|
815
|
+
page.journal.push(action)
|
|
816
|
+
pagehandler.put(req.params[0], page, e => {
|
|
817
|
+
if (e) return res.e(e)
|
|
818
|
+
res.send('ok')
|
|
819
|
+
// log 'saved'
|
|
820
|
+
})
|
|
821
|
+
// update sitemap
|
|
822
|
+
sitemaphandler.update(req.params[0], page)
|
|
823
|
+
|
|
824
|
+
// update site index
|
|
825
|
+
searchhandler.update(req.params[0], page)
|
|
826
|
+
}
|
|
827
|
+
// log action
|
|
828
|
+
|
|
829
|
+
// If the action is a fork, get the page from the remote server,
|
|
830
|
+
// otherwise ask pagehandler for it.
|
|
831
|
+
if (action.fork) {
|
|
832
|
+
pagehandler.saveToRecycler(req.params[0], err => {
|
|
833
|
+
if (err && err !== 'page does not exist') {
|
|
834
|
+
console.log(`Error saving ${req.params[0]} before fork: ${err}`)
|
|
835
|
+
}
|
|
836
|
+
if (action.forkPage) {
|
|
837
|
+
const forkPageCopy = JSON.parse(JSON.stringify(action.forkPage))
|
|
838
|
+
delete action.forkPage
|
|
839
|
+
actionCB(null, forkPageCopy)
|
|
840
|
+
} else {
|
|
841
|
+
// Legacy path, new clients will provide forkPage on implicit forks.
|
|
842
|
+
remoteGet(action.fork, req.params[0], actionCB)
|
|
843
|
+
}
|
|
844
|
+
})
|
|
845
|
+
} else if (action.type === 'create') {
|
|
846
|
+
// Prevent attempt to write circular structure
|
|
847
|
+
const itemCopy = JSON.parse(JSON.stringify(action.item))
|
|
848
|
+
pagehandler.get(req.params[0], (e, page, status) => {
|
|
849
|
+
if (e) return actionCB(e)
|
|
850
|
+
if (status !== 404) {
|
|
851
|
+
res.status(409).send('Page already exists.')
|
|
852
|
+
} else {
|
|
853
|
+
actionCB(null, itemCopy)
|
|
854
|
+
}
|
|
855
|
+
})
|
|
856
|
+
} else if (action.type === 'fork') {
|
|
857
|
+
pagehandler.saveToRecycler(req.params[0], err => {
|
|
858
|
+
if (err) console.log(`Error saving ${req.params[0]} before fork: ${err}`)
|
|
859
|
+
if (action.forkPage) {
|
|
860
|
+
// push
|
|
861
|
+
const forkPageCopy = JSON.parse(JSON.stringify(action.forkPage))
|
|
862
|
+
delete action.forkPage
|
|
863
|
+
actionCB(null, forkPageCopy)
|
|
864
|
+
} else {
|
|
865
|
+
// pull
|
|
866
|
+
remoteGet(action.site, req.params[0], actionCB)
|
|
867
|
+
}
|
|
868
|
+
})
|
|
869
|
+
} else {
|
|
870
|
+
pagehandler.get(req.params[0], actionCB)
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// Return the oops page when login fails.
|
|
875
|
+
app.get('/oops', (req, res) => {
|
|
876
|
+
res.statusCode = 403
|
|
877
|
+
res.render('oops.html', { msg: 'This is not your wiki!' })
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
// Traditional request to / redirects to index :)
|
|
881
|
+
app.get('/', cors, (req, res) => {
|
|
882
|
+
const home = path.join(argv.assets, 'home', 'index.html')
|
|
883
|
+
fs.stat(home, (err, stats) => {
|
|
884
|
+
if (err || !stats.isFile()) {
|
|
885
|
+
res.redirect(index)
|
|
886
|
+
} else {
|
|
887
|
+
res.redirect('/assets/home/index.html')
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
// ##### Delete Routes #####
|
|
893
|
+
|
|
894
|
+
app.delete(/^\/([a-z0-9-]+)\.json$/, authorized, (req, res) => {
|
|
895
|
+
const pageFile = req.params[0]
|
|
896
|
+
// we need the original page text to remove it from the index, so get the original text before deleting it
|
|
897
|
+
pagehandler.get(pageFile, (e, page, status) => {
|
|
898
|
+
const title = page.title
|
|
899
|
+
pagehandler.delete(pageFile, err => {
|
|
900
|
+
if (err) {
|
|
901
|
+
res.status(500).send(err)
|
|
902
|
+
} else {
|
|
903
|
+
sitemaphandler.removePage(pageFile)
|
|
904
|
+
res.status(200).send('')
|
|
905
|
+
// update site index
|
|
906
|
+
searchhandler.removePage(req.params[0])
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
// #### Start the server ####
|
|
913
|
+
//
|
|
914
|
+
// set a default process exitCode, so we can diferentiate between exiting as part of a reload,
|
|
915
|
+
// and an exit after an uncaught error.
|
|
916
|
+
// except when test is set, so the tests don't report a fail when closing the server process.
|
|
917
|
+
process.exitCode = argv.test ? 0 : 1
|
|
918
|
+
|
|
919
|
+
// Wait to make sure owner is known before listening.
|
|
920
|
+
securityhandler.retrieveOwner(e => {
|
|
921
|
+
// Throw if you can't find the initial owner
|
|
922
|
+
if (e) throw e
|
|
923
|
+
owner = securityhandler.getOwner()
|
|
924
|
+
console.log('owner: ' + owner)
|
|
925
|
+
app.emit('owner-set')
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
app.on('running-serv', server => {
|
|
929
|
+
// ### Plugins ###
|
|
930
|
+
// Should replace most WebSocketServers below.
|
|
931
|
+
const plugins = pluginsFactory(argv)
|
|
932
|
+
plugins.startServers({ argv, app })
|
|
933
|
+
// ### Sitemap ###
|
|
934
|
+
// create sitemap at start-up
|
|
935
|
+
sitemaphandler.createSitemap(pagehandler)
|
|
936
|
+
// create site index at start-up
|
|
937
|
+
searchhandler.startUp(pagehandler)
|
|
938
|
+
})
|
|
939
|
+
|
|
940
|
+
// Return app when called, so that it can be watched for events and shutdown with .close() externally.
|
|
941
|
+
return app
|
|
942
|
+
}
|