karaoke-eternal 1.0.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.
Files changed (79) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +49 -0
  3. package/assets/app.ico +0 -0
  4. package/assets/app.png +0 -0
  5. package/assets/favicon.ico +0 -0
  6. package/assets/mic-blackTemplate.png +0 -0
  7. package/assets/mic-blackTemplate@2x.png +0 -0
  8. package/assets/mic-white.png +0 -0
  9. package/assets/mic-white@2x.png +0 -0
  10. package/assets/robots.txt +2 -0
  11. package/build/267.4be526e3a94d53aeceae.js +1 -0
  12. package/build/591.4be526e3a94d53aeceae.js +1 -0
  13. package/build/598.4be526e3a94d53aeceae.css +5 -0
  14. package/build/598.4be526e3a94d53aeceae.js +1 -0
  15. package/build/799.4be526e3a94d53aeceae.js +1 -0
  16. package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
  17. package/build/845.4be526e3a94d53aeceae.css +132 -0
  18. package/build/845.4be526e3a94d53aeceae.js +1 -0
  19. package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
  20. package/build/e419b95dccb58b362811.woff2 +0 -0
  21. package/build/index.html +1 -0
  22. package/build/licenses.txt +1400 -0
  23. package/build/main.4be526e3a94d53aeceae.css +2034 -0
  24. package/build/main.4be526e3a94d53aeceae.js +1 -0
  25. package/package.json +144 -0
  26. package/server/Library/Library.js +340 -0
  27. package/server/Library/index.js +3 -0
  28. package/server/Library/ipc.js +18 -0
  29. package/server/Library/router.js +27 -0
  30. package/server/Library/socket.js +47 -0
  31. package/server/Media/Media.js +207 -0
  32. package/server/Media/index.js +3 -0
  33. package/server/Media/ipc.js +19 -0
  34. package/server/Media/router.js +99 -0
  35. package/server/Player/socket.js +78 -0
  36. package/server/Prefs/Prefs.js +165 -0
  37. package/server/Prefs/index.js +3 -0
  38. package/server/Prefs/router.js +124 -0
  39. package/server/Prefs/socket.js +68 -0
  40. package/server/Queue/Queue.js +208 -0
  41. package/server/Queue/index.js +3 -0
  42. package/server/Queue/socket.js +99 -0
  43. package/server/Rooms/Rooms.js +114 -0
  44. package/server/Rooms/index.js +3 -0
  45. package/server/Rooms/router.js +146 -0
  46. package/server/Scanner/FileScanner/FileScanner.js +225 -0
  47. package/server/Scanner/FileScanner/getConfig.js +35 -0
  48. package/server/Scanner/FileScanner/getFiles.js +63 -0
  49. package/server/Scanner/FileScanner/index.js +3 -0
  50. package/server/Scanner/MetaParser/MetaParser.js +49 -0
  51. package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
  52. package/server/Scanner/MetaParser/index.js +3 -0
  53. package/server/Scanner/Scanner.js +33 -0
  54. package/server/User/User.js +139 -0
  55. package/server/User/index.js +3 -0
  56. package/server/User/router.js +442 -0
  57. package/server/lib/Database.js +55 -0
  58. package/server/lib/IPCBridge.js +115 -0
  59. package/server/lib/Log.js +71 -0
  60. package/server/lib/bcrypt.js +24 -0
  61. package/server/lib/cli.js +136 -0
  62. package/server/lib/electron.js +81 -0
  63. package/server/lib/getCdgName.js +20 -0
  64. package/server/lib/getDevMiddleware.js +51 -0
  65. package/server/lib/getFolders.js +10 -0
  66. package/server/lib/getHotMiddleware.js +27 -0
  67. package/server/lib/getIPAddress.js +16 -0
  68. package/server/lib/getPermutations.js +21 -0
  69. package/server/lib/getWindowsDrives.js +30 -0
  70. package/server/lib/parseCookie.js +12 -0
  71. package/server/lib/pushQueuesAndLibrary.js +29 -0
  72. package/server/lib/schemas/001-initial-schema.sql +98 -0
  73. package/server/lib/schemas/002-replaygain.sql +9 -0
  74. package/server/lib/schemas/003-queue-linked-list.sql +16 -0
  75. package/server/main.js +135 -0
  76. package/server/scannerWorker.js +58 -0
  77. package/server/serverWorker.js +242 -0
  78. package/server/socket.js +173 -0
  79. package/shared/actionTypes.js +103 -0
@@ -0,0 +1,139 @@
1
+ const db = require('../lib/Database').db
2
+ const sql = require('sqlate')
3
+ const Queue = require('../Queue')
4
+
5
+ class User {
6
+ /**
7
+ * Get user by userId
8
+ *
9
+ * @param {Number} userId
10
+ * @param {Bool} creds Whether to include username and password in result
11
+ * @return {Promise}
12
+ */
13
+ static async getById (userId, creds = false) {
14
+ if (typeof userId !== 'number') {
15
+ throw new Error('userId must be a number')
16
+ }
17
+
18
+ return User._get({ userId }, creds)
19
+ }
20
+
21
+ /**
22
+ * Get user by username
23
+ *
24
+ * @param {String} username
25
+ * @param {Bool} creds Whether to include username and password in result
26
+ * @return {Promise}
27
+ */
28
+ static async getByUsername (username, creds = false) {
29
+ if (typeof username !== 'string') {
30
+ throw new Error('username must be a string')
31
+ }
32
+
33
+ return User._get({ username }, creds)
34
+ }
35
+
36
+ /**
37
+ * Gets all users
38
+ *
39
+ * @return {Promise} normalized list of users
40
+ */
41
+ static async get () {
42
+ const result = []
43
+ const entities = {}
44
+
45
+ const query = sql`
46
+ SELECT userId, username, name, isAdmin, dateCreated, dateUpdated
47
+ FROM users
48
+ ORDER BY dateCreated DESC
49
+ `
50
+ const res = await db.all(String(query), query.parameters)
51
+
52
+ res.forEach(row => {
53
+ result.push(row.userId)
54
+
55
+ row.isAdmin = row.isAdmin === 1
56
+ entities[row.userId] = row
57
+ })
58
+
59
+ return { result, entities }
60
+ }
61
+
62
+ /**
63
+ * Remove a user
64
+ *
65
+ * @param {Number} userId
66
+ * @return {Promise}
67
+ */
68
+ static async remove (userId) {
69
+ if (typeof userId !== 'number') {
70
+ throw new Error('userId must be a number')
71
+ }
72
+
73
+ // remove user's queue items
74
+ const queueQuery = sql`
75
+ SELECT queueId
76
+ FROM queue
77
+ WHERE userId = ${userId}
78
+ `
79
+ const queueRows = await db.all(String(queueQuery), queueQuery.parameters)
80
+
81
+ for (const row of queueRows) {
82
+ await Queue.remove(row.queueId)
83
+ }
84
+
85
+ // remove user's song stars
86
+ const songStarsQuery = sql`
87
+ DELETE FROM songStars
88
+ WHERE userId = ${userId}
89
+ `
90
+ await db.run(String(songStarsQuery), songStarsQuery.parameters)
91
+
92
+ // remove user's artist stars
93
+ const artistStarsQuery = sql`
94
+ DELETE FROM artistStars
95
+ WHERE userId = ${userId}
96
+ `
97
+ await db.run(String(artistStarsQuery), artistStarsQuery.parameters)
98
+
99
+ // remove the user
100
+ const usersQuery = sql`
101
+ DELETE FROM users
102
+ WHERE userId = ${userId}
103
+ `
104
+ const usersQueryRes = await db.run(String(usersQuery), usersQuery.parameters)
105
+
106
+ if (!usersQueryRes.changes) {
107
+ throw new Error(`unable to remove userId: ${userId}`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * (private) runs the query
113
+ * @param {Object} id with fields 'username' or 'userId'
114
+ * @param {Bool} creds whether to include username and password in result
115
+ * @return {Promise} user object
116
+ */
117
+ static async _get ({ userId, username }, creds) {
118
+ const query = sql`
119
+ SELECT *
120
+ FROM users
121
+ WHERE ${typeof userId === 'number' ? sql`userId = ${userId}` : sql`username = ${username}`}
122
+ `
123
+
124
+ const user = await db.get(String(query), query.parameters)
125
+ if (!user) return false
126
+
127
+ if (!creds) {
128
+ delete user.username
129
+ delete user.password
130
+ }
131
+
132
+ // client expects boolean
133
+ user.isAdmin = user.isAdmin === 1
134
+
135
+ return user
136
+ }
137
+ }
138
+
139
+ module.exports = User
@@ -0,0 +1,3 @@
1
+ const User = require('./User')
2
+
3
+ module.exports = User
@@ -0,0 +1,442 @@
1
+ const { promisify } = require('util')
2
+ const fs = require('fs')
3
+ const readFile = promisify(fs.readFile)
4
+ const deleteFile = promisify(fs.unlink)
5
+ const db = require('../lib/Database').db
6
+ const sql = require('sqlate')
7
+ const jwtSign = require('jsonwebtoken').sign
8
+ const bcrypt = require('../lib/bcrypt')
9
+ const KoaRouter = require('koa-router')
10
+ const router = KoaRouter({ prefix: '/api' })
11
+ const Prefs = require('../Prefs')
12
+ const Queue = require('../Queue')
13
+ const Rooms = require('../Rooms')
14
+ const User = require('../User')
15
+ const {
16
+ QUEUE_PUSH,
17
+ } = require('../../shared/actionTypes')
18
+
19
+ const BCRYPT_ROUNDS = 12
20
+ const USERNAME_MIN_LENGTH = 3
21
+ const USERNAME_MAX_LENGTH = 128
22
+ const PASSWORD_MIN_LENGTH = 6
23
+ const NAME_MIN_LENGTH = 2
24
+ const NAME_MAX_LENGTH = 50
25
+ const IMG_MAX_LENGTH = 50000 // bytes
26
+
27
+ // login
28
+ router.post('/login', async (ctx, next) => {
29
+ await _login(ctx, ctx.request.body)
30
+ })
31
+
32
+ // logout
33
+ router.get('/logout', async (ctx, next) => {
34
+ // @todo force socket room leave
35
+ ctx.cookies.set('keToken', '')
36
+ ctx.status = 200
37
+ ctx.body = {}
38
+ })
39
+
40
+ // get own account (helps sync account changes across devices)
41
+ router.get('/user', async (ctx, next) => {
42
+ if (typeof ctx.user.userId !== 'number') {
43
+ ctx.throw(401)
44
+ }
45
+
46
+ // include credentials since their username may have changed
47
+ const user = await User.getById(ctx.user.userId, true)
48
+
49
+ if (!user) {
50
+ ctx.throw(404)
51
+ }
52
+
53
+ // don't include in response
54
+ delete user.image
55
+ delete user.password
56
+
57
+ ctx.body = user
58
+ })
59
+
60
+ // list all users (admin only)
61
+ router.get('/users', async (ctx, next) => {
62
+ if (!ctx.user.isAdmin) {
63
+ ctx.throw(401)
64
+ }
65
+
66
+ const userRooms = {} // { userId: [roomId, roomId, ...]}
67
+
68
+ for (const s of ctx.io.of('/').sockets.values()) {
69
+ if (s.user && typeof s.user.roomId === 'number') {
70
+ if (userRooms[s.user.userId]) {
71
+ userRooms[s.user.userId].push(s.user.roomId)
72
+ } else {
73
+ userRooms[s.user.userId] = [s.user.roomId]
74
+ }
75
+ }
76
+ }
77
+
78
+ // get all users
79
+ const users = await User.get()
80
+
81
+ users.result.forEach(userId => {
82
+ users.entities[userId].rooms = userRooms[userId] || []
83
+ })
84
+
85
+ ctx.body = users
86
+ })
87
+
88
+ // delete a user (admin only)
89
+ router.delete('/user/:userId', async (ctx, next) => {
90
+ const targetId = parseInt(ctx.params.userId, 10)
91
+
92
+ if (!ctx.user.isAdmin || targetId === ctx.user.userId) {
93
+ ctx.throw(403)
94
+ }
95
+
96
+ await User.remove(targetId)
97
+
98
+ // disconnect their socket session(s)
99
+ for (const s of ctx.io.of('/').sockets.values()) {
100
+ if (s.user && s.user.userId === targetId) {
101
+ s.disconnect()
102
+ }
103
+ }
104
+
105
+ // emit (potentially) updated queues to each room
106
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
107
+ ctx.io.to(room).emit('action', {
108
+ type: QUEUE_PUSH,
109
+ payload: await Queue.get(roomId),
110
+ })
111
+ }
112
+
113
+ // success
114
+ ctx.status = 200
115
+ ctx.body = {}
116
+ })
117
+
118
+ // update a user account
119
+ router.put('/user/:userId', async (ctx, next) => {
120
+ const targetId = parseInt(ctx.params.userId, 10)
121
+ const user = await User.getById(ctx.user.userId, true)
122
+
123
+ // must be admin if updating another user
124
+ if (!user || (targetId !== user.userId && !user.isAdmin)) {
125
+ ctx.throw(401)
126
+ }
127
+
128
+ let { name, username, password, newPassword, newPasswordConfirm } = ctx.request.body
129
+
130
+ // validate current password if updating own account
131
+ if (targetId === user.userId) {
132
+ if (!password) {
133
+ ctx.throw(422, 'Current password is required')
134
+ }
135
+
136
+ if (!await bcrypt.compare(password, user.password)) {
137
+ ctx.throw(401, 'Incorrect current password')
138
+ }
139
+ }
140
+
141
+ // validated
142
+ const fields = new Map()
143
+
144
+ // changing username?
145
+ if (username) {
146
+ username = username.trim()
147
+
148
+ if (username.lenth < USERNAME_MIN_LENGTH || username.length > USERNAME_MAX_LENGTH) {
149
+ ctx.throw(400, `Username or email must have ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`)
150
+ }
151
+
152
+ // check for duplicate
153
+ if (await User.getByUsername(username)) {
154
+ ctx.throw(409, 'Username or email is not available')
155
+ }
156
+
157
+ fields.set('username', username)
158
+ }
159
+
160
+ // changing display name?
161
+ if (name) {
162
+ name = name.trim()
163
+
164
+ if (name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) {
165
+ ctx.throw(400, `Display name must have ${NAME_MIN_LENGTH}-${NAME_MAX_LENGTH} characters`)
166
+ }
167
+
168
+ fields.set('name', name)
169
+ }
170
+
171
+ // changing password?
172
+ if (newPassword) {
173
+ if (newPassword.length < PASSWORD_MIN_LENGTH) {
174
+ ctx.throw(400, `Password must have at least ${PASSWORD_MIN_LENGTH} characters`)
175
+ }
176
+
177
+ if (newPassword !== newPasswordConfirm) {
178
+ ctx.throw(422, 'New passwords do not match')
179
+ }
180
+
181
+ fields.set('password', await bcrypt.hash(newPassword, BCRYPT_ROUNDS))
182
+ }
183
+
184
+ // changing user image?
185
+ if (ctx.request.files.image) {
186
+ fields.set('image', await readFile(ctx.request.files.image.filepath))
187
+ await deleteFile(ctx.request.files.image.filepath)
188
+ } else if (ctx.request.body.image === 'null') {
189
+ fields.set('image', null)
190
+ }
191
+
192
+ // changing role?
193
+ if (ctx.request.body.role) {
194
+ // @todo since we're not ensuring there'd be at least one admin
195
+ // remaining, changing one's own role is currently disallowed
196
+ if (!user.isAdmin || targetId === user.userId) {
197
+ ctx.throw(403)
198
+ }
199
+
200
+ fields.set('isAdmin', parseInt(ctx.request.body.role, 10))
201
+ }
202
+
203
+ fields.set('dateUpdated', Math.floor(Date.now() / 1000))
204
+
205
+ const query = sql`
206
+ UPDATE users
207
+ SET ${sql.tuple(Array.from(fields.keys()).map(sql.column))} = ${sql.tuple(Array.from(fields.values()))}
208
+ WHERE userId = ${targetId}
209
+ `
210
+ const res = await db.run(String(query), query.parameters)
211
+
212
+ if (!res.changes) {
213
+ ctx.throw(404, `userId ${targetId} not found`)
214
+ }
215
+
216
+ // emit (potentially) updated queues to each room
217
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
218
+ ctx.io.to(room).emit('action', {
219
+ type: QUEUE_PUSH,
220
+ payload: await Queue.get(roomId),
221
+ })
222
+ }
223
+
224
+ // we're done if updating another account
225
+ if (targetId !== user.userId) {
226
+ ctx.status = 200
227
+ ctx.body = {}
228
+ return
229
+ }
230
+
231
+ // send updated token if updating own account
232
+ await _login(ctx, {
233
+ username: username || user.username,
234
+ password: newPassword || password,
235
+ roomId: ctx.user.roomId || null,
236
+ }, false) // don't require room password to update account
237
+ })
238
+
239
+ // create account
240
+ router.post('/user', async (ctx, next) => {
241
+ if (!ctx.user.isAdmin) {
242
+ // already signed in as non-admin?
243
+ if (ctx.user.userId !== null) {
244
+ ctx.throw(403, 'You are already signed in')
245
+ }
246
+
247
+ // trying to specify a role?
248
+ if (ctx.request.body.role) {
249
+ ctx.throw(403)
250
+ }
251
+
252
+ // new users must choose a room at the same time
253
+ try {
254
+ await Rooms.validate(ctx.request.body.roomId, ctx.request.body.roomPassword)
255
+ } catch (err) {
256
+ ctx.throw(401, err.message)
257
+ }
258
+ }
259
+
260
+ // create user
261
+ await _create(ctx, ctx.request.body.role === '1')
262
+
263
+ // success
264
+ ctx.status = 200
265
+ ctx.body = {}
266
+ })
267
+
268
+ // first-time setup
269
+ router.post('/setup', async (ctx, next) => {
270
+ // must be first run
271
+ const prefs = await Prefs.get()
272
+
273
+ if (prefs.isFirstRun !== true) {
274
+ ctx.throw(403)
275
+ }
276
+
277
+ // create admin user
278
+ await _create(ctx, true)
279
+
280
+ // create default room
281
+ const fields = new Map()
282
+ fields.set('name', 'Room 1')
283
+ fields.set('status', 'open')
284
+ fields.set('dateCreated', Math.floor(Date.now() / 1000))
285
+
286
+ const query = sql`
287
+ INSERT INTO rooms ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
288
+ VALUES ${sql.tuple(Array.from(fields.values()))}
289
+ `
290
+ const res = await db.run(String(query), query.parameters)
291
+
292
+ if (typeof res.lastID !== 'number') {
293
+ ctx.throw(500, 'Invalid default room lastID')
294
+ }
295
+
296
+ // unset isFirstRun
297
+ {
298
+ const query = sql`
299
+ UPDATE prefs
300
+ SET data = 'false'
301
+ WHERE key = 'isFirstRun'
302
+ `
303
+ await db.run(String(query))
304
+ }
305
+
306
+ // success
307
+ ctx.status = 200
308
+ ctx.body = {
309
+ roomId: res.lastID,
310
+ }
311
+ })
312
+
313
+ // get a user's image
314
+ router.get('/user/:userId/image', async (ctx, next) => {
315
+ const userId = parseInt(ctx.params.userId, 10)
316
+ const user = await User.getById(userId)
317
+
318
+ if (!user || !user.image) {
319
+ ctx.throw(404)
320
+ }
321
+
322
+ if (typeof ctx.query.v !== 'undefined') {
323
+ // client can cache a versioned image forever
324
+ ctx.set('Cache-Control', 'max-age=31536000') // 1 year
325
+ }
326
+
327
+ ctx.type = 'image/jpeg'
328
+ ctx.body = user.image
329
+ })
330
+
331
+ module.exports = router
332
+
333
+ async function _create (ctx, isAdmin = false) {
334
+ let { name, username, newPassword, newPasswordConfirm } = ctx.request.body
335
+
336
+ if (!username || !name || !newPassword || !newPasswordConfirm) {
337
+ ctx.throw(422, 'All fields are required')
338
+ }
339
+
340
+ username = username.trim()
341
+ name = name.trim()
342
+
343
+ if (username.lenth < USERNAME_MIN_LENGTH || username.length > USERNAME_MAX_LENGTH) {
344
+ ctx.throw(400, `Username or email must have ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`)
345
+ }
346
+
347
+ if (newPassword.length < PASSWORD_MIN_LENGTH) {
348
+ ctx.throw(400, `Password must have at least ${PASSWORD_MIN_LENGTH} characters`)
349
+ }
350
+
351
+ if (name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) {
352
+ ctx.throw(400, `Display name must have ${NAME_MIN_LENGTH}-${NAME_MAX_LENGTH} characters`)
353
+ }
354
+
355
+ if (newPassword !== newPasswordConfirm) {
356
+ ctx.throw(422, 'New passwords do not match')
357
+ }
358
+
359
+ // check for duplicate username
360
+ if (await User.getByUsername(username)) {
361
+ ctx.throw(409, 'Username or email is not available')
362
+ }
363
+
364
+ const fields = new Map()
365
+ fields.set('username', username)
366
+ fields.set('password', await bcrypt.hash(newPassword, BCRYPT_ROUNDS))
367
+ fields.set('name', name)
368
+ fields.set('dateCreated', Math.floor(Date.now() / 1000))
369
+ fields.set('isAdmin', isAdmin ? 1 : 0)
370
+
371
+ // user image?
372
+ if (ctx.request.files.image) {
373
+ const img = await readFile(ctx.request.files.image.filepath)
374
+ await deleteFile(ctx.request.files.image.filepath)
375
+
376
+ // client should resize before uploading to be
377
+ // well below this limit, but just in case...
378
+ if (img.length > IMG_MAX_LENGTH) {
379
+ ctx.throw(413, 'Invalid image')
380
+ }
381
+
382
+ fields.set('image', img)
383
+ }
384
+
385
+ const query = sql`
386
+ INSERT INTO users ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
387
+ VALUES ${sql.tuple(Array.from(fields.values()))}
388
+ `
389
+
390
+ await db.run(String(query), query.parameters)
391
+ }
392
+
393
+ async function _login (ctx, creds, validateRoomPassword = true) {
394
+ const { username, password, roomPassword } = creds
395
+ const roomId = parseInt(creds.roomId, 10) || null
396
+
397
+ if (!username || !password) {
398
+ ctx.throw(422, 'Username/email and password are required')
399
+ }
400
+
401
+ const user = await User.getByUsername(username, true)
402
+
403
+ if (!user || !await bcrypt.compare(password, user.password)) {
404
+ ctx.throw(401, 'Incorrect username/email or password')
405
+ }
406
+
407
+ if (roomId) {
408
+ try {
409
+ // admins can sign in to closed rooms
410
+ await Rooms.validate(roomId, roomPassword, {
411
+ isOpen: !user.isAdmin,
412
+ validatePassword: validateRoomPassword,
413
+ })
414
+ } catch (err) {
415
+ ctx.throw(401, err.message)
416
+ }
417
+ } else if (!user.isAdmin) {
418
+ ctx.throw(422, 'Please select a room')
419
+ }
420
+
421
+ user.roomId = roomId
422
+
423
+ // encrypt JWT based on subset of user object
424
+ const token = jwtSign({
425
+ dateUpdated: user.dateUpdated,
426
+ isAdmin: user.isAdmin,
427
+ name: user.name,
428
+ roomId: user.roomId,
429
+ userId: user.userId,
430
+ }, ctx.jwtKey)
431
+
432
+ // set JWT as an httpOnly cookie
433
+ ctx.cookies.set('keToken', token, {
434
+ httpOnly: true,
435
+ })
436
+
437
+ // don't want these in the response
438
+ delete user.password
439
+ delete user.image
440
+
441
+ ctx.body = user
442
+ }
@@ -0,0 +1,55 @@
1
+ const path = require('path')
2
+ const fse = require('fs-extra')
3
+ const sqlite3 = require('sqlite3')
4
+ const { open } = require('sqlite')
5
+ const log = require('./Log')('db')
6
+ let _db
7
+
8
+ class Database {
9
+ static async close () {
10
+ // depending on how the node process was started, it can receive
11
+ // multiple SIGINTs or SIGTERMs in the same tick, so we clear the
12
+ // reference first to avoid calling close() multiple times
13
+ if (_db) {
14
+ const db = _db
15
+ _db = null
16
+
17
+ log.info('Closing database file %s', db.config.filename)
18
+ await db.close()
19
+ }
20
+ }
21
+
22
+ static async open ({ file, ro = true } = {}) {
23
+ if (_db) throw new Error('Database already open')
24
+
25
+ log.info('Opening database file %s %s', ro ? '(read-only)' : '(writeable)', file)
26
+
27
+ // create path if it doesn't exist
28
+ fse.ensureDirSync(path.dirname(file))
29
+
30
+ const db = await open({
31
+ filename: file,
32
+ driver: sqlite3.Database,
33
+ mode: ro ? sqlite3.OPEN_READONLY : null,
34
+ })
35
+
36
+ if (!ro) {
37
+ await db.migrate({
38
+ migrationsPath: path.join(__dirname, 'schemas'),
39
+ })
40
+
41
+ await db.run('PRAGMA journal_mode = WAL;')
42
+ await db.run('PRAGMA foreign_keys = ON;')
43
+ }
44
+
45
+ _db = db
46
+ return _db
47
+ }
48
+
49
+ static get db () {
50
+ if (!_db) throw new Error('Database not yet open')
51
+ return _db
52
+ }
53
+ }
54
+
55
+ module.exports = Database