karaoke-eternal 1.0.0 → 2.0.0-beta.6

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 (128) hide show
  1. package/README.md +10 -10
  2. package/build/client/447.83b0127845c2fa8729fe.js +1 -0
  3. package/build/client/715.83b0127845c2fa8729fe.js +1 -0
  4. package/build/client/718.83b0127845c2fa8729fe.js +1 -0
  5. package/build/client/851.83b0127845c2fa8729fe.js +1 -0
  6. package/build/{845.4be526e3a94d53aeceae.css → client/958.83b0127845c2fa8729fe.css} +53 -6
  7. package/build/client/958.83b0127845c2fa8729fe.js +1 -0
  8. package/build/{index.html → client/index.html} +1 -1
  9. package/build/{licenses.txt → client/licenses.txt} +208 -496
  10. package/build/client/main.83b0127845c2fa8729fe.css +2341 -0
  11. package/build/client/main.83b0127845c2fa8729fe.js +1 -0
  12. package/build/server/Library/Library.js +297 -0
  13. package/build/server/Library/ipc.js +13 -0
  14. package/build/server/Library/router.js +20 -0
  15. package/build/server/Library/socket.js +35 -0
  16. package/build/server/Media/Media.js +170 -0
  17. package/build/server/Media/fileTypes.js +8 -0
  18. package/build/server/Media/ipc.js +13 -0
  19. package/build/server/Media/router.js +97 -0
  20. package/build/server/Player/socket.js +66 -0
  21. package/build/server/Prefs/Prefs.js +181 -0
  22. package/build/server/Prefs/router.js +151 -0
  23. package/build/server/Prefs/socket.js +52 -0
  24. package/build/server/Queue/Queue.js +203 -0
  25. package/build/server/Queue/socket.js +83 -0
  26. package/build/server/Rooms/Rooms.js +171 -0
  27. package/build/server/Rooms/router.js +97 -0
  28. package/build/server/Rooms/socket.js +23 -0
  29. package/build/server/Scanner/FileScanner/FileScanner.js +166 -0
  30. package/build/server/Scanner/FileScanner/getConfig.js +32 -0
  31. package/build/server/Scanner/FileScanner/getFiles.js +61 -0
  32. package/build/server/Scanner/MetaParser/MetaParser.js +77 -0
  33. package/build/server/Scanner/MetaParser/defaultMiddleware.js +170 -0
  34. package/build/server/Scanner/Scanner.js +26 -0
  35. package/build/server/Scanner/ScannerQueue.js +62 -0
  36. package/build/server/User/User.js +206 -0
  37. package/build/server/User/router.js +366 -0
  38. package/build/server/lib/Database.js +39 -0
  39. package/build/server/lib/Errors.js +6 -0
  40. package/build/server/lib/IPCBridge.js +128 -0
  41. package/build/server/lib/Log.js +31 -0
  42. package/build/server/lib/accumulatedThrottle.js +16 -0
  43. package/build/server/lib/bcrypt.js +23 -0
  44. package/build/server/lib/cli.js +131 -0
  45. package/build/server/lib/getCdgName.js +18 -0
  46. package/build/server/lib/getFolders.js +8 -0
  47. package/build/server/lib/getHotMiddleware.js +22 -0
  48. package/build/server/lib/getIPAddress.js +14 -0
  49. package/build/server/lib/getPermutations.js +17 -0
  50. package/build/server/lib/getWindowsDrives.js +17 -0
  51. package/build/server/lib/parseCookie.js +13 -0
  52. package/build/server/lib/pushQueuesAndLibrary.js +22 -0
  53. package/{server → build/server}/lib/schemas/001-initial-schema.sql +26 -26
  54. package/build/server/lib/schemas/004-paths-rooms-data.sql +7 -0
  55. package/build/server/lib/schemas/005-roles.sql +32 -0
  56. package/build/server/lib/util.js +39 -0
  57. package/build/server/main.js +124 -0
  58. package/build/server/scannerWorker.js +59 -0
  59. package/build/server/serverWorker.js +219 -0
  60. package/build/server/socket.js +134 -0
  61. package/build/server/watcherWorker.js +51 -0
  62. package/build/shared/actionTypes.js +113 -0
  63. package/build/shared/types.js +1 -0
  64. package/package.json +111 -86
  65. package/build/267.4be526e3a94d53aeceae.js +0 -1
  66. package/build/591.4be526e3a94d53aeceae.js +0 -1
  67. package/build/598.4be526e3a94d53aeceae.js +0 -1
  68. package/build/799.4be526e3a94d53aeceae.js +0 -1
  69. package/build/845.4be526e3a94d53aeceae.js +0 -1
  70. package/build/main.4be526e3a94d53aeceae.css +0 -2034
  71. package/build/main.4be526e3a94d53aeceae.js +0 -1
  72. package/server/Library/Library.js +0 -340
  73. package/server/Library/index.js +0 -3
  74. package/server/Library/ipc.js +0 -18
  75. package/server/Library/router.js +0 -27
  76. package/server/Library/socket.js +0 -47
  77. package/server/Media/Media.js +0 -207
  78. package/server/Media/index.js +0 -3
  79. package/server/Media/ipc.js +0 -19
  80. package/server/Media/router.js +0 -99
  81. package/server/Player/socket.js +0 -78
  82. package/server/Prefs/Prefs.js +0 -165
  83. package/server/Prefs/index.js +0 -3
  84. package/server/Prefs/router.js +0 -124
  85. package/server/Prefs/socket.js +0 -68
  86. package/server/Queue/Queue.js +0 -208
  87. package/server/Queue/index.js +0 -3
  88. package/server/Queue/socket.js +0 -99
  89. package/server/Rooms/Rooms.js +0 -114
  90. package/server/Rooms/index.js +0 -3
  91. package/server/Rooms/router.js +0 -146
  92. package/server/Scanner/FileScanner/FileScanner.js +0 -225
  93. package/server/Scanner/FileScanner/getConfig.js +0 -35
  94. package/server/Scanner/FileScanner/getFiles.js +0 -63
  95. package/server/Scanner/FileScanner/index.js +0 -3
  96. package/server/Scanner/MetaParser/MetaParser.js +0 -49
  97. package/server/Scanner/MetaParser/defaultMiddleware.js +0 -197
  98. package/server/Scanner/MetaParser/index.js +0 -3
  99. package/server/Scanner/Scanner.js +0 -33
  100. package/server/User/User.js +0 -139
  101. package/server/User/index.js +0 -3
  102. package/server/User/router.js +0 -442
  103. package/server/lib/Database.js +0 -55
  104. package/server/lib/IPCBridge.js +0 -115
  105. package/server/lib/Log.js +0 -71
  106. package/server/lib/bcrypt.js +0 -24
  107. package/server/lib/cli.js +0 -136
  108. package/server/lib/electron.js +0 -81
  109. package/server/lib/getCdgName.js +0 -20
  110. package/server/lib/getDevMiddleware.js +0 -51
  111. package/server/lib/getFolders.js +0 -10
  112. package/server/lib/getHotMiddleware.js +0 -27
  113. package/server/lib/getIPAddress.js +0 -16
  114. package/server/lib/getPermutations.js +0 -21
  115. package/server/lib/getWindowsDrives.js +0 -30
  116. package/server/lib/parseCookie.js +0 -12
  117. package/server/lib/pushQueuesAndLibrary.js +0 -29
  118. package/server/main.js +0 -135
  119. package/server/scannerWorker.js +0 -58
  120. package/server/serverWorker.js +0 -242
  121. package/server/socket.js +0 -173
  122. package/shared/actionTypes.js +0 -103
  123. /package/build/{7ce9eb3fe454f54745a4.woff2 → client/7ce9eb3fe454f54745a4.woff2} +0 -0
  124. /package/build/{598.4be526e3a94d53aeceae.css → client/851.83b0127845c2fa8729fe.css} +0 -0
  125. /package/build/{a35814dd9eb496e3d7cc.woff2 → client/a35814dd9eb496e3d7cc.woff2} +0 -0
  126. /package/build/{e419b95dccb58b362811.woff2 → client/e419b95dccb58b362811.woff2} +0 -0
  127. /package/{server → build/server}/lib/schemas/002-replaygain.sql +0 -0
  128. /package/{server → build/server}/lib/schemas/003-queue-linked-list.sql +0 -0
@@ -0,0 +1,62 @@
1
+ import FileScanner from './FileScanner/FileScanner.js';
2
+ import Prefs from '../Prefs/Prefs.js';
3
+ import getLogger from '../lib/Log.js';
4
+ const log = getLogger('queue');
5
+ class ScannerQueue {
6
+ #instance;
7
+ #isCanceling = false;
8
+ #q = [];
9
+ onIteration;
10
+ onDone;
11
+ constructor(onIteration, onDone) {
12
+ this.onIteration = onIteration;
13
+ this.onDone = onDone;
14
+ }
15
+ async queue(pathIds) {
16
+ const prefs = await Prefs.get();
17
+ if (pathIds === true) {
18
+ pathIds = prefs.paths.result; // queueing all paths
19
+ }
20
+ else if (Number.isInteger(pathIds)) {
21
+ pathIds = [pathIds];
22
+ }
23
+ if (!Array.isArray(pathIds)) {
24
+ log.warn('invalid pathIds: %s', pathIds);
25
+ return;
26
+ }
27
+ pathIds.forEach((id) => {
28
+ const dir = prefs.paths.entities[id]?.path;
29
+ if (!dir) {
30
+ log.warn('ignoring (invalid pathId): %s', id);
31
+ }
32
+ else if (this.#q.includes(id)) {
33
+ log.info('ignoring (path already queued): %s', dir);
34
+ }
35
+ else {
36
+ log.info('path queued for scan: %s', dir);
37
+ this.#q.push(id);
38
+ }
39
+ });
40
+ if (this.#q.length && !this.#instance) {
41
+ this.start();
42
+ }
43
+ }
44
+ async start() {
45
+ log.info('Starting media scan');
46
+ while (this.#q.length && !this.#isCanceling) {
47
+ const prefs = await Prefs.get();
48
+ this.#instance = new FileScanner(prefs, { length: this.#q.length });
49
+ const stats = await this.#instance.scan(this.#q.shift());
50
+ this.onIteration(stats);
51
+ }
52
+ this.onDone();
53
+ }
54
+ stop() {
55
+ log.info('Stopping media scan (user requested)');
56
+ this.#isCanceling = true;
57
+ if (this.#instance) {
58
+ this.#instance.cancel();
59
+ }
60
+ }
61
+ }
62
+ export default ScannerQueue;
@@ -0,0 +1,206 @@
1
+ import Database from '../lib/Database.js';
2
+ import sql from 'sqlate';
3
+ import bcrypt from '../lib/bcrypt.js';
4
+ import Queue from '../Queue/Queue.js';
5
+ import { randomChars } from '../lib/util.js';
6
+ export const IMG_MAX_LENGTH = 50000; // bytes
7
+ export const BCRYPT_ROUNDS = 12;
8
+ export const USERNAME_MIN_LENGTH = 3;
9
+ export const USERNAME_MAX_LENGTH = 128;
10
+ export const PASSWORD_MIN_LENGTH = 6;
11
+ export const NAME_MIN_LENGTH = 2;
12
+ export const NAME_MAX_LENGTH = 50;
13
+ const { db } = Database;
14
+ class User {
15
+ /**
16
+ * Get user by userId
17
+ *
18
+ * @param {Number} userId
19
+ * @param {Bool} creds Whether to include username and password in result
20
+ * @return {Promise}
21
+ */
22
+ static async getById(userId, creds = false) {
23
+ if (typeof userId !== 'number') {
24
+ throw new Error('userId must be a number');
25
+ }
26
+ return User._get({ userId, username: undefined }, creds);
27
+ }
28
+ /**
29
+ * Get user by username
30
+ *
31
+ * @param {String} username
32
+ * @param {Bool} creds Whether to include username and password in result
33
+ * @return {Promise}
34
+ */
35
+ static async getByUsername(username, creds = false) {
36
+ if (typeof username !== 'string') {
37
+ throw new Error('username must be a string');
38
+ }
39
+ return User._get({ userId: undefined, username }, creds);
40
+ }
41
+ /**
42
+ * Gets all users
43
+ *
44
+ * @return {Promise} normalized list of users
45
+ */
46
+ static async get() {
47
+ const result = [];
48
+ const entities = {};
49
+ const query = sql `
50
+ SELECT users.userId, users.username, users.name, users.dateCreated, users.dateUpdated, roles.name AS role
51
+ FROM users
52
+ INNER JOIN roles USING (roleId)
53
+ ORDER BY dateCreated DESC
54
+ `;
55
+ const res = await db.all(String(query), query.parameters);
56
+ res.forEach((row) => {
57
+ result.push(row.userId);
58
+ entities[row.userId] = row;
59
+ });
60
+ return { result, entities };
61
+ }
62
+ static async create({ username, newPassword, newPasswordConfirm, name, image, }, role = 'standard') {
63
+ username = username?.trim();
64
+ name = name?.trim();
65
+ const fields = new Map();
66
+ if (role !== 'guest') {
67
+ if (!username) {
68
+ throw new Error('Username or email is required');
69
+ }
70
+ if (username.length < USERNAME_MIN_LENGTH || username.length > USERNAME_MAX_LENGTH) {
71
+ throw new Error(`Username or email must have ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`);
72
+ }
73
+ if (!newPassword) {
74
+ throw new Error('Password is required');
75
+ }
76
+ if (newPassword.length < PASSWORD_MIN_LENGTH) {
77
+ throw new Error(`Password must have at least ${PASSWORD_MIN_LENGTH} characters`);
78
+ }
79
+ if (!newPasswordConfirm) {
80
+ throw new Error('Password confirmation is required');
81
+ }
82
+ if (newPassword !== newPasswordConfirm) {
83
+ throw new Error('New passwords do not match');
84
+ }
85
+ if (await User.getByUsername(username)) {
86
+ throw new Error('Username or email is not available');
87
+ }
88
+ fields.set('username', username);
89
+ fields.set('password', await bcrypt.hash(newPassword, BCRYPT_ROUNDS));
90
+ }
91
+ else {
92
+ let res = {};
93
+ // ensure unique guest username
94
+ do {
95
+ fields.set('username', `guest-${randomChars(5)}`);
96
+ const query = sql `
97
+ SELECT COUNT(*) AS count
98
+ FROM users
99
+ WHERE username = ${fields.get('username')}
100
+ `;
101
+ res = await db.get(String(query), query.parameters);
102
+ } while (res.count > 0);
103
+ fields.set('password', 'guest');
104
+ }
105
+ if (!name) {
106
+ throw new Error('Display name is required');
107
+ }
108
+ if (name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) {
109
+ throw new Error(`Display name must have ${NAME_MIN_LENGTH}-${NAME_MAX_LENGTH} characters`);
110
+ }
111
+ fields.set('name', name);
112
+ fields.set('dateCreated', Math.floor(Date.now() / 1000));
113
+ fields.set('roleId', sql `(SELECT roleId FROM roles WHERE name = ${role})`);
114
+ // user image?
115
+ if (image) {
116
+ if (image.length > IMG_MAX_LENGTH) {
117
+ throw new Error('Invalid image');
118
+ }
119
+ fields.set('image', image);
120
+ }
121
+ const query = sql `
122
+ INSERT INTO users ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
123
+ VALUES ${sql.tuple(Array.from(fields.values()))}
124
+ `;
125
+ const res = await db.run(String(query), query.parameters);
126
+ if (typeof res.lastID !== 'number') {
127
+ throw new Error('Unable to create user');
128
+ }
129
+ return res.lastID;
130
+ }
131
+ static async validate({ username, password }) {
132
+ if (!username || !password) {
133
+ throw new Error('Username/email and password are required');
134
+ }
135
+ const user = await User.getByUsername(username, true);
136
+ if (!user || !(await bcrypt.compare(password, user.password))) {
137
+ throw new Error('Incorrect username/email or password');
138
+ }
139
+ return user;
140
+ }
141
+ /**
142
+ * Remove a user
143
+ *
144
+ * @param {Number} userId
145
+ * @return {Promise}
146
+ */
147
+ static async remove(userId) {
148
+ if (typeof userId !== 'number') {
149
+ throw new Error('userId must be a number');
150
+ }
151
+ // remove user's queue items
152
+ const queueQuery = sql `
153
+ SELECT queueId
154
+ FROM queue
155
+ WHERE userId = ${userId}
156
+ `;
157
+ const queueRows = await db.all(String(queueQuery), queueQuery.parameters);
158
+ for (const row of queueRows) {
159
+ await Queue.remove(row.queueId);
160
+ }
161
+ // remove user's song stars
162
+ const songStarsQuery = sql `
163
+ DELETE FROM songStars
164
+ WHERE userId = ${userId}
165
+ `;
166
+ await db.run(String(songStarsQuery), songStarsQuery.parameters);
167
+ // remove user's artist stars
168
+ const artistStarsQuery = sql `
169
+ DELETE FROM artistStars
170
+ WHERE userId = ${userId}
171
+ `;
172
+ await db.run(String(artistStarsQuery), artistStarsQuery.parameters);
173
+ // remove the user
174
+ const usersQuery = sql `
175
+ DELETE FROM users
176
+ WHERE userId = ${userId}
177
+ `;
178
+ const usersQueryRes = await db.run(String(usersQuery), usersQuery.parameters);
179
+ if (!usersQueryRes.changes) {
180
+ throw new Error(`unable to remove userId: ${userId}`);
181
+ }
182
+ }
183
+ /**
184
+ * (private) runs the query
185
+ * @param {Object} id with fields 'username' or 'userId'
186
+ * @param {Bool} creds whether to include username and password in result
187
+ * @return {Promise} user object
188
+ */
189
+ static async _get({ userId, username }, creds = false) {
190
+ const query = sql `
191
+ SELECT users.*, roles.name AS role
192
+ FROM users
193
+ INNER JOIN roles USING (roleId)
194
+ WHERE ${typeof userId === 'number' ? sql `userId = ${userId}` : sql `LOWER(username) = ${username.toLowerCase()}`}
195
+ `;
196
+ const user = await db.get(String(query), query.parameters);
197
+ if (!user)
198
+ return false;
199
+ if (!creds) {
200
+ delete user.username;
201
+ delete user.password;
202
+ }
203
+ return user;
204
+ }
205
+ }
206
+ export default User;
@@ -0,0 +1,366 @@
1
+ import { promisify } from 'util';
2
+ import fs from 'fs';
3
+ import Database from '../lib/Database.js';
4
+ import sql from 'sqlate';
5
+ import jsonWebToken from 'jsonwebtoken';
6
+ import bcrypt from '../lib/bcrypt.js';
7
+ import KoaRouter from '@koa/router';
8
+ import Prefs from '../Prefs/Prefs.js';
9
+ import Queue from '../Queue/Queue.js';
10
+ import Rooms from '../Rooms/Rooms.js';
11
+ import User from '../User/User.js';
12
+ import { QUEUE_PUSH } from '../../shared/actionTypes.js';
13
+ import { BCRYPT_ROUNDS, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, PASSWORD_MIN_LENGTH, NAME_MIN_LENGTH, NAME_MAX_LENGTH } from './User.js';
14
+ const router = new KoaRouter({ prefix: '/api' });
15
+ const { db } = Database;
16
+ const readFile = promisify(fs.readFile);
17
+ const deleteFile = promisify(fs.unlink);
18
+ const { sign: jwtSign } = jsonWebToken;
19
+ // Takes the "raw" object returned by the User class and massages it
20
+ // into the shape used by the client (state.user) and in server-side
21
+ // routers. Should be used to generate the JWT.
22
+ const createUserCtx = (user, roomId) => {
23
+ return {
24
+ dateCreated: user.dateCreated,
25
+ dateUpdated: user.dateUpdated,
26
+ isAdmin: user.role === 'admin',
27
+ isGuest: user.role === 'guest',
28
+ name: user.name,
29
+ roomId: parseInt(roomId, 10) || null,
30
+ userId: user.userId,
31
+ username: user.username,
32
+ };
33
+ };
34
+ // login
35
+ router.post('/login', async (ctx) => {
36
+ const roomId = parseInt(ctx.request.body.roomId, 10) || null;
37
+ let user;
38
+ try {
39
+ user = await User.validate(ctx.request.body);
40
+ if (roomId) {
41
+ await Rooms.validate(roomId, ctx.request.body.roomPassword, {
42
+ isOpen: user.role !== 'admin', // admins can sign in to closed rooms
43
+ validatePassword: true,
44
+ });
45
+ }
46
+ else if (user.role !== 'admin') {
47
+ ctx.throw(401, 'Please select a room');
48
+ }
49
+ }
50
+ catch (err) {
51
+ ctx.throw(401, err.message);
52
+ }
53
+ const userCtx = createUserCtx(user, roomId);
54
+ // create JWT
55
+ const token = jwtSign(userCtx, ctx.jwtKey);
56
+ // set JWT as an httpOnly cookie
57
+ ctx.cookies.set('keToken', token, {
58
+ httpOnly: true,
59
+ });
60
+ ctx.body = userCtx;
61
+ });
62
+ // logout
63
+ router.get('/logout', async (ctx) => {
64
+ // @todo force socket room leave
65
+ ctx.cookies.set('keToken', '');
66
+ ctx.status = 200;
67
+ ctx.body = {};
68
+ });
69
+ // get own account (helps sync account changes across devices)
70
+ router.get('/user', async (ctx) => {
71
+ if (typeof ctx.user.userId !== 'number') {
72
+ ctx.throw(401);
73
+ }
74
+ // include credentials since their username may have changed
75
+ const user = await User.getById(ctx.user.userId, true);
76
+ if (!user) {
77
+ ctx.throw(404);
78
+ }
79
+ ctx.body = createUserCtx(user, ctx.user.roomId);
80
+ });
81
+ // list all users (admin only)
82
+ router.get('/users', async (ctx) => {
83
+ if (!ctx.user.isAdmin) {
84
+ ctx.throw(401);
85
+ }
86
+ const userRooms = {}; // { userId: [roomId, roomId, ...]}
87
+ const sockets = await ctx.io.fetchSockets();
88
+ for (const s of sockets) {
89
+ if (s.user && typeof s.user.roomId === 'number') {
90
+ if (userRooms[s.user.userId]) {
91
+ userRooms[s.user.userId].push(s.user.roomId);
92
+ }
93
+ else {
94
+ userRooms[s.user.userId] = [s.user.roomId];
95
+ }
96
+ }
97
+ }
98
+ // get all users
99
+ const users = await User.get();
100
+ users.result.forEach((userId) => {
101
+ users.entities[userId].rooms = userRooms[userId] || [];
102
+ });
103
+ ctx.body = users;
104
+ });
105
+ // delete a user (admin only)
106
+ router.delete('/user/:userId', async (ctx) => {
107
+ const targetId = parseInt(ctx.params.userId, 10);
108
+ if (!ctx.user.isAdmin || targetId === ctx.user.userId) {
109
+ ctx.throw(403);
110
+ }
111
+ await User.remove(targetId);
112
+ // disconnect their socket session(s)
113
+ const sockets = await ctx.io.fetchSockets();
114
+ for (const s of sockets) {
115
+ if (s?.user.userId === targetId) {
116
+ s.disconnect();
117
+ }
118
+ }
119
+ // emit (potentially) updated queues to each room
120
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
121
+ ctx.io.to(room).emit('action', {
122
+ type: QUEUE_PUSH,
123
+ payload: await Queue.get(roomId),
124
+ });
125
+ }
126
+ // success
127
+ ctx.status = 200;
128
+ ctx.body = {};
129
+ });
130
+ // update a user account
131
+ router.put('/user/:userId', async (ctx) => {
132
+ const targetId = parseInt(ctx.params.userId, 10);
133
+ const user = await User.getById(ctx.user.userId, true);
134
+ // must be admin if updating another user
135
+ if (!user || (targetId !== user.userId && user.role !== 'admin')) {
136
+ ctx.throw(401);
137
+ }
138
+ let { name, username, password, newPassword, newPasswordConfirm } = ctx.request.body;
139
+ // validate current password if updating own account
140
+ if (targetId === user.userId && !ctx.user.isGuest) {
141
+ if (!password) {
142
+ ctx.throw(422, 'Current password is required');
143
+ }
144
+ if (!(await bcrypt.compare(password, user.password))) {
145
+ ctx.throw(401, 'Incorrect current password');
146
+ }
147
+ }
148
+ // validated
149
+ const fields = new Map();
150
+ // changing username?
151
+ if (username && !ctx.user.isGuest) {
152
+ username = username.trim();
153
+ if (username.length < USERNAME_MIN_LENGTH || username.length > USERNAME_MAX_LENGTH) {
154
+ ctx.throw(400, `Username or email must have ${USERNAME_MIN_LENGTH}-${USERNAME_MAX_LENGTH} characters`);
155
+ }
156
+ // check for duplicate
157
+ if (await User.getByUsername(username)) {
158
+ ctx.throw(409, 'Username or email is not available');
159
+ }
160
+ fields.set('username', username);
161
+ }
162
+ // changing display name?
163
+ if (name) {
164
+ name = name.trim();
165
+ if (name.length < NAME_MIN_LENGTH || name.length > NAME_MAX_LENGTH) {
166
+ ctx.throw(400, `Display name must have ${NAME_MIN_LENGTH}-${NAME_MAX_LENGTH} characters`);
167
+ }
168
+ fields.set('name', name);
169
+ }
170
+ // changing password?
171
+ if (newPassword && !ctx.user.isGuest) {
172
+ if (newPassword.length < PASSWORD_MIN_LENGTH) {
173
+ ctx.throw(400, `Password must have at least ${PASSWORD_MIN_LENGTH} characters`);
174
+ }
175
+ if (newPassword !== newPasswordConfirm) {
176
+ ctx.throw(422, 'New passwords do not match');
177
+ }
178
+ fields.set('password', await bcrypt.hash(newPassword, BCRYPT_ROUNDS));
179
+ }
180
+ // changing user image?
181
+ if (ctx.request.files.image) {
182
+ const imageFile = Array.isArray(ctx.request.files.image) ? ctx.request.files.image[0] : ctx.request.files.image;
183
+ fields.set('image', await readFile(imageFile.filepath));
184
+ await deleteFile(imageFile.filepath);
185
+ }
186
+ else if (ctx.request.body.image === 'null') {
187
+ fields.set('image', null);
188
+ }
189
+ // changing role?
190
+ if (ctx.request.body.role) {
191
+ // @todo since we're not ensuring there'd be at least one admin
192
+ // remaining, changing one's own role is currently disallowed
193
+ if (user.role !== 'admin' || targetId === user.userId) {
194
+ ctx.throw(403);
195
+ }
196
+ fields.set('roleId', sql `(SELECT roleId FROM roles WHERE name = ${ctx.request.body.role})`);
197
+ }
198
+ fields.set('dateUpdated', Math.floor(Date.now() / 1000));
199
+ const query = sql `
200
+ UPDATE users
201
+ SET ${sql.tuple(Array.from(fields.keys()).map(sql.column))} = ${sql.tuple(Array.from(fields.values()))}
202
+ WHERE userId = ${targetId}
203
+ `;
204
+ const res = await db.run(String(query), query.parameters);
205
+ if (!res.changes) {
206
+ ctx.throw(404, `userId ${targetId} not found`);
207
+ }
208
+ // emit (potentially) updated queues to each room
209
+ // @todo: only update rooms the user is in
210
+ for (const { room, roomId } of Rooms.getActive(ctx.io)) {
211
+ ctx.io.to(room).emit('action', {
212
+ type: QUEUE_PUSH,
213
+ payload: await Queue.get(roomId),
214
+ });
215
+ }
216
+ // updating another account? we're done
217
+ if (targetId !== user.userId) {
218
+ ctx.status = 200;
219
+ ctx.body = {};
220
+ return;
221
+ }
222
+ // updating own account: send updated token
223
+ let updatedUser;
224
+ if (user.role !== 'guest') {
225
+ try {
226
+ updatedUser = await User.validate({
227
+ username: username || user.username,
228
+ password: newPassword || password,
229
+ });
230
+ }
231
+ catch (err) {
232
+ ctx.throw(401, err.message);
233
+ }
234
+ }
235
+ else {
236
+ updatedUser = {
237
+ ...user,
238
+ name: name || user.name,
239
+ };
240
+ }
241
+ const userCtx = createUserCtx(updatedUser, ctx.user.roomId || null);
242
+ // create JWT
243
+ // @todo: this should not extend the JWT expiry date
244
+ const token = jwtSign(userCtx, ctx.jwtKey);
245
+ // set JWT as an httpOnly cookie
246
+ ctx.cookies.set('keToken', token, {
247
+ httpOnly: true,
248
+ });
249
+ ctx.body = userCtx;
250
+ });
251
+ // create account
252
+ router.post('/user', async (ctx) => {
253
+ let image;
254
+ if (!ctx.user.isAdmin) {
255
+ // already signed in?
256
+ if (ctx.user.userId !== null) {
257
+ ctx.throw(401, 'You are already signed in');
258
+ }
259
+ // only possible roles; further validated per-room below
260
+ if (!['guest', 'standard'].includes(ctx.request.body.role)) {
261
+ ctx.throw(401, 'Invalid role');
262
+ }
263
+ // new users must choose a room at the same time
264
+ try {
265
+ await Rooms.validate(ctx.request.body.roomId, ctx.request.body.roomPassword, { role: ctx.request.body.role });
266
+ }
267
+ catch (err) {
268
+ ctx.throw(401, err.message);
269
+ }
270
+ }
271
+ if (ctx.request.files.image) {
272
+ const imageFile = Array.isArray(ctx.request.files.image) ? ctx.request.files.image[0] : ctx.request.files.image;
273
+ image = await readFile(imageFile.filepath);
274
+ await deleteFile(imageFile.filepath);
275
+ }
276
+ // create user
277
+ try {
278
+ const userId = await User.create({ ...ctx.request.body, image }, ctx.request.body.role);
279
+ // if admin creating another user, we're done
280
+ if (ctx.user.isAdmin) {
281
+ ctx.status = 200;
282
+ ctx.body = {};
283
+ return;
284
+ }
285
+ const user = await User.getById(userId, true);
286
+ const userCtx = createUserCtx(user, ctx.request.body.roomId || null);
287
+ // create JWT
288
+ const token = jwtSign(userCtx, ctx.jwtKey);
289
+ // set JWT as an httpOnly cookie
290
+ ctx.cookies.set('keToken', token, {
291
+ httpOnly: true,
292
+ });
293
+ ctx.body = userCtx;
294
+ }
295
+ catch (err) {
296
+ ctx.throw(403, err.message);
297
+ }
298
+ });
299
+ // first-time setup
300
+ router.post('/setup', async (ctx) => {
301
+ const prefs = await Prefs.get();
302
+ let image;
303
+ // must be first run
304
+ if (prefs.isFirstRun !== true) {
305
+ ctx.throw(403);
306
+ }
307
+ // create default room
308
+ const fields = new Map();
309
+ fields.set('name', 'Room 1');
310
+ fields.set('status', 'open');
311
+ fields.set('dateCreated', Math.floor(Date.now() / 1000));
312
+ const query = sql `
313
+ INSERT INTO rooms ${sql.tuple(Array.from(fields.keys()).map(sql.column))}
314
+ VALUES ${sql.tuple(Array.from(fields.values()))}
315
+ `;
316
+ const res = await db.run(String(query), query.parameters);
317
+ if (typeof res.lastID !== 'number') {
318
+ ctx.throw(500, 'Invalid default room lastID');
319
+ }
320
+ // create admin user
321
+ try {
322
+ const userId = await User.create({ ...ctx.request.body, image }, 'admin');
323
+ const user = await User.getById(userId, true);
324
+ const userCtx = createUserCtx(user, res.lastID);
325
+ // create JWT
326
+ const token = jwtSign(userCtx, ctx.jwtKey);
327
+ // set JWT as an httpOnly cookie
328
+ ctx.cookies.set('keToken', token, {
329
+ httpOnly: true,
330
+ });
331
+ // unset isFirstRun
332
+ const query = sql `
333
+ UPDATE prefs
334
+ SET data = 'false'
335
+ WHERE key = 'isFirstRun'
336
+ `;
337
+ await db.run(String(query));
338
+ // success
339
+ ctx.body = userCtx;
340
+ }
341
+ catch (err) {
342
+ ctx.throw(403, err.message);
343
+ }
344
+ });
345
+ // get a user's image
346
+ router.get('/user/:userId/image', async (ctx) => {
347
+ const targetId = parseInt(ctx.params.userId, 10);
348
+ if (ctx.user.userId !== targetId && !ctx.user.isAdmin) {
349
+ // ensure users are in the same room
350
+ const sockets = await ctx.io.in(Rooms.prefix(ctx.user.roomId)).fetchSockets();
351
+ if (!sockets.some(s => s?.user && s?.user.userId === targetId)) {
352
+ ctx.throw(403);
353
+ }
354
+ }
355
+ const user = await User.getById(targetId);
356
+ if (!user || !user.image) {
357
+ ctx.throw(404);
358
+ }
359
+ if (typeof ctx.query.v !== 'undefined') {
360
+ // client can cache a versioned image forever
361
+ ctx.set('Cache-Control', 'max-age=31536000'); // 1 year
362
+ }
363
+ ctx.type = 'image/jpeg';
364
+ ctx.body = user.image;
365
+ });
366
+ export default router;
@@ -0,0 +1,39 @@
1
+ import path from 'path';
2
+ import fse from 'fs-extra';
3
+ import sqlite3 from 'sqlite3';
4
+ import { open as sqliteOpen } from 'sqlite';
5
+ import getLogger from './Log.js';
6
+ const log = getLogger('db');
7
+ class Database {
8
+ static refs = {};
9
+ static async close() {
10
+ if (Database.refs.db) {
11
+ log.info('Closing database file %s', Database.refs.db.config.filename);
12
+ await Database.refs.db.close();
13
+ }
14
+ }
15
+ static async open({ file, ro = true } = { file: '', ro: true }) {
16
+ if (Database.refs.db)
17
+ throw new Error('Database already open');
18
+ log.info('Opening database file %s %s', ro ? '(read-only)' : '(writeable)', file);
19
+ // create path if it doesn't exist
20
+ fse.ensureDirSync(path.dirname(file));
21
+ const db = await sqliteOpen({
22
+ filename: file,
23
+ driver: sqlite3.Database,
24
+ mode: ro ? sqlite3.OPEN_READONLY : null,
25
+ });
26
+ if (!ro) {
27
+ await db.migrate({
28
+ migrationsPath: path.join(import.meta.dirname, 'schemas'),
29
+ });
30
+ await db.run('PRAGMA journal_mode = WAL;');
31
+ await db.run('PRAGMA foreign_keys = ON;');
32
+ }
33
+ Database.refs.db = db;
34
+ return db;
35
+ }
36
+ }
37
+ export const open = Database.open;
38
+ export const close = Database.close;
39
+ export default Database.refs;
@@ -0,0 +1,6 @@
1
+ export class ValidationError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'ValidationError';
5
+ }
6
+ }