wiki-server 0.25.10 → 0.26.0-rc.1

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 CHANGED
@@ -19,3 +19,4 @@ Eric Dobbs <eric@dobbse.net>
19
19
  Joshua Benuck <joshua_benuck@yahoo.com>
20
20
  Tom Lieber <tom@alltom.com>
21
21
  Andrew Shell <andrew@andrewshell.org>
22
+ jon r <jon@allmende.io>
package/index.js CHANGED
@@ -2,11 +2,7 @@
2
2
  // Simple file so that if you require this directory
3
3
  // in node it instead requires ./lib/server.coffee
4
4
  // with coffee-script already loaded.
5
- require('coffeescript');
6
- require('coffeescript/register');
5
+ require('coffeescript')
6
+ require('coffeescript/register')
7
7
 
8
- // set a default process exitCode, so we can diferentiate between exiting as
9
- // part of a reload, and an exit after an uncaught error
10
- process.exitCode = 1
11
-
12
- module.exports = require('./lib/server');
8
+ module.exports = require('./lib/server')
@@ -0,0 +1,83 @@
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
+ // **defaultargs.coffee** when called on the argv object this
10
+ // module will create reasonable defaults for options not supplied,
11
+ // based on what information is provided.
12
+ const path = require('node:path')
13
+
14
+ const getUserHome = () => {
15
+ return process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE
16
+ }
17
+
18
+ module.exports = argv => {
19
+ argv = argv || {}
20
+ argv.root ||= __dirname
21
+ // the directory that contains all the packages that makeup the wiki
22
+ argv.packageDir ||= path.join(argv.root, '..')
23
+ argv.port ||= 3000
24
+ argv.home ||= 'welcome-visitors'
25
+ argv.data ||= path.join(getUserHome(), '.wiki') // see also cli
26
+ argv.client ||= path.join(argv.packageDir, 'wiki-client', 'client')
27
+ argv.db ||= path.join(argv.data, 'pages')
28
+ argv.status ||= path.join(argv.data, 'status')
29
+ argv.assets ||= path.join(argv.data, 'assets')
30
+ argv.recycler ||= path.join(argv.data, 'recycle')
31
+ argv.commons ||= path.join(argv.data, 'commons')
32
+ argv.url ||= `http://localhost${argv.port === 80 ? '' : ':' + argv.port}`
33
+ argv.id ||= path.join(argv.status, 'owner.json')
34
+ argv.uploadLimit ||= '5mb'
35
+ argv.cookieSecret ||= require('crypto').randomBytes(64).toString('hex')
36
+ argv.secure_cookie ||= false
37
+ argv.session_duration ||= 7
38
+ argv.neighbors ||= ''
39
+ argv.debug ||= false
40
+ argv.test ||= false
41
+
42
+ if (typeof argv.database === 'string') {
43
+ argv.database = JSON.parse(argv.database)
44
+ }
45
+ argv.database ||= {}
46
+ argv.database.type ||= './page'
47
+ if (argv.database.type.charAt(0) === '.') {
48
+ if (argv.database.type != './page') {
49
+ console.log('\n\nWARNING: This storage option is depeciated.')
50
+ console.log(' See ReadMe for details of the changes required.\n\n')
51
+ }
52
+ } else {
53
+ argv.database.type = 'wiki-storage-' + argv.database.type
54
+ }
55
+
56
+ argv.security_type ||= './security'
57
+ if (argv.security_type === './security') {
58
+ console.log('\n\nINFORMATION: Using default security module.')
59
+ } else {
60
+ argv.security_type = 'wiki-security-' + argv.security_type
61
+ }
62
+ argv.security_legacy ||= false
63
+
64
+ // resolve all relative paths
65
+ argv.root = path.resolve(argv.root)
66
+ argv.packageDir = path.resolve(argv.packageDir)
67
+ argv.data = path.resolve(argv.data)
68
+ argv.client = path.resolve(argv.client)
69
+ argv.db = path.resolve(argv.db)
70
+ argv.status = path.resolve(argv.status)
71
+ argv.assets = path.resolve(argv.assets)
72
+ argv.recycler = path.resolve(argv.recycler)
73
+ argv.commons = path.resolve(argv.commons)
74
+ argv.id = path.resolve(argv.id)
75
+
76
+ if (/node_modules/.test(argv.data)) {
77
+ console.log('\n\nWARNING : The dafault data path is not a safe place.')
78
+ console.log(' : by using ', argv.data, ' your pages will be lost when packages are updated.')
79
+ console.log(' : You are strongly advised to use an alternative directory.')
80
+ console.log(' : See the wiki package ReadMe for how to do this.\n\n')
81
+ }
82
+ return argv
83
+ }
package/lib/forward.js CHANGED
@@ -1,12 +1,12 @@
1
1
  const init = (app, emitter) => {
2
2
  let sockets = []
3
- app.io.on('connection', (socket) => {
3
+ app.io.on('connection', socket => {
4
4
  let listeners = []
5
5
  sockets.push(socket)
6
6
  console.log('client connected:', socket.handshake.address)
7
- socket.on('disconnect', (reason) => {
7
+ socket.on('disconnect', reason => {
8
8
  console.log('client disconnected:', socket.handshake.address, reason)
9
- for (let {sProducer, listener} of listeners) {
9
+ for (let { sProducer, listener } of listeners) {
10
10
  console.log('removing listener:', sProducer)
11
11
  emitter.removeListener(sProducer, listener)
12
12
  }
@@ -14,9 +14,9 @@ const init = (app, emitter) => {
14
14
  let i = sockets.indexOf(socket)
15
15
  sockets.splice(i, 1)
16
16
  })
17
- socket.on('unsubscribe', (sProducer) => {
17
+ socket.on('unsubscribe', sProducer => {
18
18
  console.log('unsubscribing listener:', socket.handshake.address, sProducer)
19
- for (let [i, {slugItem, listener}] of listeners.entries()) {
19
+ for (let [i, { slugItem, listener }] of listeners.entries()) {
20
20
  if (slugItem == sProducer) {
21
21
  console.log('removing listener:', sProducer)
22
22
  emitter.removeListener(sProducer, listener)
@@ -24,15 +24,15 @@ const init = (app, emitter) => {
24
24
  }
25
25
  }
26
26
  })
27
- socket.on('subscribe', (sProducer) => {
28
- let listener = (result) => {
27
+ socket.on('subscribe', sProducer => {
28
+ let listener = result => {
29
29
  console.log('forwarding:', socket.handshake.address, result)
30
- socket.emit(sProducer, {slugItem: sProducer, result})
30
+ socket.emit(sProducer, { slugItem: sProducer, result })
31
31
  }
32
32
  console.log(`registering listener:`, socket.handshake.address, sProducer)
33
33
  emitter.on(sProducer, listener)
34
- listeners.push({sProducer, listener})
34
+ listeners.push({ sProducer, listener })
35
35
  })
36
36
  })
37
37
  }
38
- module.exports = {init}
38
+ module.exports = { init }
package/lib/page.js ADDED
@@ -0,0 +1,435 @@
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
+ // **page.coffee**
9
+ // Module for interacting with pages persisted on the server.
10
+ // Everything is stored using json flat files.
11
+
12
+ // #### Requires ####
13
+ const fs = require('fs')
14
+ const path = require('path')
15
+ const events = require('events')
16
+
17
+ const async = require('async')
18
+
19
+ const random_id = require('./random_id')
20
+ const synopsis = require('wiki-client/lib/synopsis')
21
+
22
+ const asSlug = name =>
23
+ name
24
+ .replace(/\s/g, '-')
25
+ .replace(/[^A-Za-z0-9-]/g, '')
26
+ .toLowerCase()
27
+
28
+ // Export a function that generates a page handler
29
+ // when called with options object.
30
+ module.exports = exports = argv => {
31
+ const wikiName = new URL(argv.url).hostname
32
+
33
+ fs.mkdir(argv.db, { recursive: true }, e => {
34
+ if (e) throw e
35
+ })
36
+
37
+ // create a list of plugin pages.
38
+ const pluginPages = new Map()
39
+ Object.keys(require.main.require('./package').dependencies)
40
+ .filter(depend => depend.startsWith('wiki-plugin'))
41
+ .forEach(plugin => {
42
+ const pagesPath = path.join(
43
+ path.dirname(require.resolve(`${plugin}/package`, { paths: require.main.paths })),
44
+ 'pages',
45
+ )
46
+ fs.readdir(pagesPath, { withFileTypes: true }, (err, entries) => {
47
+ if (err) return
48
+ entries.forEach(entry => {
49
+ if (entry.isFile() && !pluginPages.has(entry.name)) {
50
+ pluginPages.set(entry.name, { pluginName: plugin, pluginPath: entry.parentPath })
51
+ }
52
+ })
53
+ })
54
+ })
55
+
56
+ // #### Private utility methods. ####
57
+ const load_parse = (loc, cb, annotations = {}) => {
58
+ let page
59
+ fs.readFile(loc, (err, data) => {
60
+ if (err) return cb(err)
61
+ try {
62
+ page = JSON.parse(data)
63
+ } catch {
64
+ const errorPage = path.basename(loc)
65
+ const errorPagePath = path.dirname(loc)
66
+ const recyclePage = path.resolve(errorPagePath, '..', 'recycle', errorPage)
67
+ fs.access(path.dirname(recyclePage), fs.constants.F_OK, err => {
68
+ if (!err) {
69
+ fs.rename(loc, recyclePage, err => {
70
+ if (err) {
71
+ console.log(`ERROR: moving problem page ${loc} to recycler`, err)
72
+ } else {
73
+ console.log(`ERROR: problem page ${loc} moved to recycler`)
74
+ }
75
+ })
76
+ } else {
77
+ fs.mkdir(path.dirname(recyclePage), { recursive: true }, err => {
78
+ if (err) {
79
+ console.log('ERROR: creating recycler', err)
80
+ } else {
81
+ fs.rename(loc, recyclePage, err => {
82
+ if (err) {
83
+ console.log(`ERROR: moving problem page ${loc} to recycler`, err)
84
+ } else {
85
+ console.log(`ERROR: problem page ${loc} moved to recycler`)
86
+ }
87
+ })
88
+ }
89
+ })
90
+ }
91
+ })
92
+
93
+ return cb(null, 'Error Parsing Page', 404)
94
+ }
95
+ for (const [key, val] of Object.entries(annotations)) {
96
+ page[key] = val
97
+ }
98
+ cb(null, page)
99
+ })
100
+ }
101
+
102
+ const load_parse_copy = (defloc, file, cb) => {
103
+ fs.readFile(defloc, (err, data) => {
104
+ if (err) cb(err)
105
+ let page
106
+ try {
107
+ page = JSON.parse(data)
108
+ } catch (e) {
109
+ return cb(e)
110
+ }
111
+ cb(null, page)
112
+ // TODO: what is happening here?! put will never be reached???
113
+ itself.put(file, page, err => {
114
+ if (err) cb(err)
115
+ })
116
+ })
117
+ }
118
+ // Reads and writes are async, but serially queued to avoid race conditions.
119
+ const queue = []
120
+
121
+ const tryDefaults = (file, cb) => {
122
+ const lastDefault = cb => {
123
+ const defloc = path.join(argv.root, 'default-data', 'pages', file)
124
+ fs.access(defloc, fs.constants.F_OK, err => {
125
+ if (!err) {
126
+ cb(defloc)
127
+ } else {
128
+ cb(null)
129
+ }
130
+ })
131
+ }
132
+ if (argv.defaults) {
133
+ const defloc = path.join(argv.data, '..', argv.defaults, 'pages', file)
134
+ fs.access(defloc, fs.constants.F_OK, err => {
135
+ if (!err) {
136
+ cb(defloc)
137
+ } else {
138
+ lastDefault(cb)
139
+ }
140
+ })
141
+ } else {
142
+ lastDefault(cb)
143
+ }
144
+ }
145
+
146
+ // Main file io function, when called without page it reads,
147
+ // when called with page it writes.
148
+ const fileio = (action, file, page, cb) => {
149
+ const loc = file.startsWith('recycler/') ? path.join(argv.recycler, file.split('/')[1]) : path.join(argv.db, file)
150
+
151
+ switch (action) {
152
+ case 'delete':
153
+ if (file.startsWith('recycler/')) {
154
+ // delete from recycler
155
+ fs.access(loc, fs.constants.F_OK, err => {
156
+ if (!err)
157
+ fs.unlink(loc, err => {
158
+ cb(err)
159
+ })
160
+ })
161
+ } else {
162
+ // move page to recycler
163
+ fs.access(loc, fs.constants.F_OK, err => {
164
+ if (!err) {
165
+ const recycleLoc = path.join(argv.recycler, file)
166
+ fs.access(path.dirname(recycleLoc), fs.constants.F_OK, err => {
167
+ if (!err) {
168
+ fs.rename(loc, recycleLoc, err => {
169
+ cb(err)
170
+ })
171
+ } else {
172
+ fs.mkdir(path.dirname(recycleLoc), { recursive: true }, err => {
173
+ if (err) cb(err)
174
+ fs.rename(loc, recycleLoc, err => {
175
+ cb(err)
176
+ })
177
+ })
178
+ }
179
+ })
180
+ } else {
181
+ cb('page does not exist')
182
+ }
183
+ })
184
+ }
185
+ break
186
+ case 'recycle': {
187
+ const copyFile = (source, target, cb) => {
188
+ const done = err => {
189
+ if (!cbCalled) {
190
+ cb(err)
191
+ cbCalled = true
192
+ }
193
+ return
194
+ }
195
+
196
+ let cbCalled = false
197
+
198
+ const rd = fs.createReadStream(source)
199
+ rd.on('error', err => {
200
+ done(err)
201
+ return
202
+ })
203
+
204
+ const wr = fs.createWriteStream(target)
205
+ wr.on('error', err => {
206
+ done(err)
207
+ return
208
+ })
209
+ wr.on('close', () => {
210
+ done()
211
+ return
212
+ })
213
+ rd.pipe(wr)
214
+ return
215
+ }
216
+
217
+ fs.access(loc, fs.constants.F_OK, err => {
218
+ if (!err) {
219
+ const recycleLoc = path.join(argv.recycler, file)
220
+ fs.access(path.dirname(recycleLoc), fs.constants.F_OK, err => {
221
+ if (!err) {
222
+ copyFile(loc, recycleLoc, err => {
223
+ cb(err)
224
+ })
225
+ } else {
226
+ fs.mkdir(path.dirname(recycleLoc), { recursive: true }, err => {
227
+ if (err) cb(err)
228
+ copyFile(loc, recycleLoc, err => {
229
+ cb(err)
230
+ })
231
+ })
232
+ }
233
+ })
234
+ } else {
235
+ cb('page does not exist')
236
+ }
237
+ })
238
+ break
239
+ }
240
+ case 'get':
241
+ fs.access(loc, fs.constants.F_OK, err => {
242
+ if (!err) {
243
+ load_parse(loc, cb, { plugin: undefined })
244
+ } else {
245
+ tryDefaults(file, defloc => {
246
+ if (defloc) {
247
+ load_parse(defloc, cb)
248
+ } else {
249
+ if (pluginPages.has(file)) {
250
+ const { pluginName, pluginPath } = pluginPages.get(file)
251
+ load_parse(path.join(pluginPath, file), cb, { plugin: pluginName.slice(12) })
252
+ } else {
253
+ cb(null, 'Page not found', 404)
254
+ }
255
+ }
256
+ })
257
+ }
258
+ })
259
+ break
260
+ case 'put':
261
+ page = JSON.stringify(page, null, 2)
262
+ fs.access(path.dirname(loc), fs.constants.F_OK, err => {
263
+ if (!err) {
264
+ fs.writeFile(loc, page, err => {
265
+ if (err) {
266
+ console.log(`ERROR: write file ${loc} `, err)
267
+ }
268
+ cb(err)
269
+ })
270
+ } else {
271
+ fs.mkdir(path.dirname(loc), { recursive: true }, err => {
272
+ if (err) cb(err)
273
+ fs.writeFile(loc, page, err => {
274
+ if (err) {
275
+ console.log(`ERROR: write file ${loc} `, err)
276
+ }
277
+ cb(err)
278
+ })
279
+ })
280
+ }
281
+ })
282
+ break
283
+ default:
284
+ console.log(`pagehandler: unrecognized action ${action}`)
285
+ }
286
+ }
287
+
288
+ // Control variable that tells if the serial queue is currently working.
289
+ // Set back to false when all jobs are complete.
290
+ let working = false
291
+
292
+ // Keep file io working on queued jobs, but don't block the main thread.
293
+ const serial = item => {
294
+ if (item) {
295
+ itself.start()
296
+ fileio(item.action, item.file, item.page, (err, data, status) => {
297
+ process.nextTick(() => {
298
+ serial(queue.shift())
299
+ })
300
+ item.cb(err, data, status)
301
+ })
302
+ } else {
303
+ itself.stop()
304
+ }
305
+ }
306
+
307
+ // #### Public stuff ####
308
+ // Make the exported object an instance of EventEmitter
309
+ // so other modules can tell if it is working or not.
310
+ const itself = new events.EventEmitter()
311
+
312
+ itself.start = () => {
313
+ working = true
314
+ itself.emit('working')
315
+ }
316
+
317
+ itself.stop = () => {
318
+ working = false
319
+ itself.emit('finished')
320
+ }
321
+
322
+ itself.isWorking = () => working
323
+
324
+ // get method takes a slug and a callback, adding them to the queue,
325
+ // starting serial if it isn't already working.
326
+ itself.get = (file, cb) => {
327
+ queue.push({ action: 'get', file, page: null, cb })
328
+ if (!working) serial(queue.shift())
329
+ }
330
+
331
+ // put takes a slugged name, the page as a json object, and a callback.
332
+ // adds them to the queue, and starts it unless it is working.
333
+ itself.put = (file, page, cb) => {
334
+ queue.push({ action: 'put', file, page, cb })
335
+ if (!working) serial(queue.shift())
336
+ }
337
+
338
+ itself.delete = (file, cb) => {
339
+ queue.push({ action: 'delete', file, page: null, cb })
340
+ if (!working) serial(queue.shift())
341
+ }
342
+
343
+ itself.saveToRecycler = (file, cb) => {
344
+ queue.push({ action: 'recycle', file, page: null, cb })
345
+ if (!working) serial(queue.shift())
346
+ }
347
+
348
+ const editDate = journal => {
349
+ if (!journal) return undefined
350
+ // find the last journal entry, that is not a fork, with a date.
351
+ const last = journal.findLast(action => {
352
+ return action.date && action.type != 'fork'
353
+ })
354
+ return last ? last.date : undefined
355
+ }
356
+
357
+ itself.pages = cb => {
358
+ const extractPageLinks = (collaborativeLinks, currentItem, currentIndex, array) => {
359
+ // extract collaborative links
360
+ // - this will need extending if we also extract the id of the item containing the link
361
+ try {
362
+ const linkRe = /\[\[([^\]]+)\]\]/g
363
+ let match = undefined
364
+ while ((match = linkRe.exec(currentItem.text)) != null) {
365
+ if (!collaborativeLinks.has(asSlug(match[1]))) {
366
+ collaborativeLinks.set(asSlug(match[1]), currentItem.id)
367
+ }
368
+ }
369
+ if ('reference' == currentItem.type) {
370
+ if (!collaborativeLinks.has(currentItem.slug)) {
371
+ collaborativeLinks.set(currentItem.slug, currentItem.id)
372
+ }
373
+ }
374
+ } catch (err) {
375
+ console.log(
376
+ `METADATA *** ${wikiName} Error extracting links from ${currentIndex} of ${JSON.stringify(array)}`,
377
+ err.message,
378
+ )
379
+ }
380
+ return collaborativeLinks
381
+ }
382
+ fs.readdir(argv.db, (e, files) => {
383
+ if (e) return cb(e)
384
+ // used to make sure all of the files are read
385
+ // and processesed in the site map before responding
386
+ const doSitemap = (file, cb) => {
387
+ itself.get(file, (e, page, status) => {
388
+ if (file.match(/^\./)) return cb()
389
+ if (e || status === 404) {
390
+ console.log('Problem building sitemap:', file, 'e: ', e, 'status:', status)
391
+ return cb() // Ignore errors in the pagehandler get.
392
+ }
393
+ let pageLinksMap
394
+ try {
395
+ pageLinksMap = page.story.reduce(extractPageLinks, new Map())
396
+ } catch (err) {
397
+ console.log(`METADATA *** ${wikiName} reduce to extract links on ${file} failed`, err.message)
398
+ pageLinksMap = []
399
+ }
400
+ //
401
+ const pageLinks = pageLinksMap.size > 0 ? Object.fromEntries(pageLinksMap) : undefined
402
+
403
+ cb(null, {
404
+ slug: file,
405
+ title: page.title,
406
+ date: editDate(page.journal),
407
+ synopsis: synopsis(page),
408
+ links: pageLinks,
409
+ })
410
+ })
411
+ }
412
+ async.map(files, doSitemap, (e, sitemap) => {
413
+ if (e) return cb(e)
414
+ cb(
415
+ null,
416
+ sitemap.filter(item => item != null),
417
+ )
418
+ })
419
+ })
420
+ }
421
+
422
+ itself.slugs = cb => {
423
+ fs.readdir(argv.db, { withFileTypes: true }, (e, files) => {
424
+ if (e) {
425
+ console.log('Problem reading pages directory', e)
426
+ return cb(e)
427
+ }
428
+
429
+ const onlyFiles = files.map(i => (i.isFile() ? i.name : null)).filter(i => i != null && !i?.startsWith('.'))
430
+ cb(null, onlyFiles)
431
+ })
432
+ }
433
+
434
+ return itself
435
+ }
package/lib/plugins.js ADDED
@@ -0,0 +1,54 @@
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
+ // support server-side plugins
10
+
11
+ const fs = require('node:fs')
12
+ const { pathToFileURL } = require('node:url')
13
+ // forward = require './forward'
14
+
15
+ module.exports = exports = argv => {
16
+ // NOTE: plugins are now in their own package directories alongside this one...
17
+ // Plugins are in directories of the form wiki-package-*
18
+ // those with a server component will have a server directory
19
+
20
+ const plugins = {}
21
+
22
+ // http://stackoverflow.com/questions/10914751/loading-node-js-modules-dynamically-based-on-route
23
+
24
+ const startServer = (params, plugin) => {
25
+ const server = `${argv.packageDir}/${plugin}/server/server.js`
26
+ fs.access(server, fs.constants.F_OK, err => {
27
+ if (!err) {
28
+ console.log('starting plugin', plugin)
29
+ import(pathToFileURL(server))
30
+ .then(exported => {
31
+ plugins[plugin] = exported
32
+ plugins[plugin].startServer?.(params)
33
+ })
34
+ .catch(e => {
35
+ console.log('failed to start plugin', plugin, e?.stack || e)
36
+ })
37
+ }
38
+ })
39
+ }
40
+
41
+ const startServers = params => {
42
+ // emitter = new events.EventEmitter()
43
+ // forward.init params.app, emitter
44
+ // params.emitter = emitter
45
+
46
+ Object.keys(require.main.require('./package').dependencies)
47
+ .filter(depend => depend.startsWith('wiki-plugin'))
48
+ .forEach(plugin => {
49
+ startServer(params, plugin)
50
+ })
51
+ }
52
+
53
+ return { startServers }
54
+ }
@@ -0,0 +1,15 @@
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
+ // **random_id.coffee**
10
+ // Simple random hex generator, takes an optional number of
11
+ // chars that defaults to 16 and returns a random id.
12
+
13
+ const random_id = (chars = 16) => [...Array(chars)].map(() => Math.floor(Math.random() * 16).toString(16)).join('')
14
+
15
+ module.exports = random_id.random_id = random_id