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.
- package/LICENSE +5 -0
- package/README.md +49 -0
- package/assets/app.ico +0 -0
- package/assets/app.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/mic-blackTemplate.png +0 -0
- package/assets/mic-blackTemplate@2x.png +0 -0
- package/assets/mic-white.png +0 -0
- package/assets/mic-white@2x.png +0 -0
- package/assets/robots.txt +2 -0
- package/build/267.4be526e3a94d53aeceae.js +1 -0
- package/build/591.4be526e3a94d53aeceae.js +1 -0
- package/build/598.4be526e3a94d53aeceae.css +5 -0
- package/build/598.4be526e3a94d53aeceae.js +1 -0
- package/build/799.4be526e3a94d53aeceae.js +1 -0
- package/build/7ce9eb3fe454f54745a4.woff2 +0 -0
- package/build/845.4be526e3a94d53aeceae.css +132 -0
- package/build/845.4be526e3a94d53aeceae.js +1 -0
- package/build/a35814dd9eb496e3d7cc.woff2 +0 -0
- package/build/e419b95dccb58b362811.woff2 +0 -0
- package/build/index.html +1 -0
- package/build/licenses.txt +1400 -0
- package/build/main.4be526e3a94d53aeceae.css +2034 -0
- package/build/main.4be526e3a94d53aeceae.js +1 -0
- package/package.json +144 -0
- package/server/Library/Library.js +340 -0
- package/server/Library/index.js +3 -0
- package/server/Library/ipc.js +18 -0
- package/server/Library/router.js +27 -0
- package/server/Library/socket.js +47 -0
- package/server/Media/Media.js +207 -0
- package/server/Media/index.js +3 -0
- package/server/Media/ipc.js +19 -0
- package/server/Media/router.js +99 -0
- package/server/Player/socket.js +78 -0
- package/server/Prefs/Prefs.js +165 -0
- package/server/Prefs/index.js +3 -0
- package/server/Prefs/router.js +124 -0
- package/server/Prefs/socket.js +68 -0
- package/server/Queue/Queue.js +208 -0
- package/server/Queue/index.js +3 -0
- package/server/Queue/socket.js +99 -0
- package/server/Rooms/Rooms.js +114 -0
- package/server/Rooms/index.js +3 -0
- package/server/Rooms/router.js +146 -0
- package/server/Scanner/FileScanner/FileScanner.js +225 -0
- package/server/Scanner/FileScanner/getConfig.js +35 -0
- package/server/Scanner/FileScanner/getFiles.js +63 -0
- package/server/Scanner/FileScanner/index.js +3 -0
- package/server/Scanner/MetaParser/MetaParser.js +49 -0
- package/server/Scanner/MetaParser/defaultMiddleware.js +197 -0
- package/server/Scanner/MetaParser/index.js +3 -0
- package/server/Scanner/Scanner.js +33 -0
- package/server/User/User.js +139 -0
- package/server/User/index.js +3 -0
- package/server/User/router.js +442 -0
- package/server/lib/Database.js +55 -0
- package/server/lib/IPCBridge.js +115 -0
- package/server/lib/Log.js +71 -0
- package/server/lib/bcrypt.js +24 -0
- package/server/lib/cli.js +136 -0
- package/server/lib/electron.js +81 -0
- package/server/lib/getCdgName.js +20 -0
- package/server/lib/getDevMiddleware.js +51 -0
- package/server/lib/getFolders.js +10 -0
- package/server/lib/getHotMiddleware.js +27 -0
- package/server/lib/getIPAddress.js +16 -0
- package/server/lib/getPermutations.js +21 -0
- package/server/lib/getWindowsDrives.js +30 -0
- package/server/lib/parseCookie.js +12 -0
- package/server/lib/pushQueuesAndLibrary.js +29 -0
- package/server/lib/schemas/001-initial-schema.sql +98 -0
- package/server/lib/schemas/002-replaygain.sql +9 -0
- package/server/lib/schemas/003-queue-linked-list.sql +16 -0
- package/server/main.js +135 -0
- package/server/scannerWorker.js +58 -0
- package/server/serverWorker.js +242 -0
- package/server/socket.js +173 -0
- 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,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
|