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.
- package/README.md +10 -10
- package/build/client/447.83b0127845c2fa8729fe.js +1 -0
- package/build/client/715.83b0127845c2fa8729fe.js +1 -0
- package/build/client/718.83b0127845c2fa8729fe.js +1 -0
- package/build/client/851.83b0127845c2fa8729fe.js +1 -0
- package/build/{845.4be526e3a94d53aeceae.css → client/958.83b0127845c2fa8729fe.css} +53 -6
- package/build/client/958.83b0127845c2fa8729fe.js +1 -0
- package/build/{index.html → client/index.html} +1 -1
- package/build/{licenses.txt → client/licenses.txt} +208 -496
- package/build/client/main.83b0127845c2fa8729fe.css +2341 -0
- package/build/client/main.83b0127845c2fa8729fe.js +1 -0
- package/build/server/Library/Library.js +297 -0
- package/build/server/Library/ipc.js +13 -0
- package/build/server/Library/router.js +20 -0
- package/build/server/Library/socket.js +35 -0
- package/build/server/Media/Media.js +170 -0
- package/build/server/Media/fileTypes.js +8 -0
- package/build/server/Media/ipc.js +13 -0
- package/build/server/Media/router.js +97 -0
- package/build/server/Player/socket.js +66 -0
- package/build/server/Prefs/Prefs.js +181 -0
- package/build/server/Prefs/router.js +151 -0
- package/build/server/Prefs/socket.js +52 -0
- package/build/server/Queue/Queue.js +203 -0
- package/build/server/Queue/socket.js +83 -0
- package/build/server/Rooms/Rooms.js +171 -0
- package/build/server/Rooms/router.js +97 -0
- package/build/server/Rooms/socket.js +23 -0
- package/build/server/Scanner/FileScanner/FileScanner.js +166 -0
- package/build/server/Scanner/FileScanner/getConfig.js +32 -0
- package/build/server/Scanner/FileScanner/getFiles.js +61 -0
- package/build/server/Scanner/MetaParser/MetaParser.js +77 -0
- package/build/server/Scanner/MetaParser/defaultMiddleware.js +170 -0
- package/build/server/Scanner/Scanner.js +26 -0
- package/build/server/Scanner/ScannerQueue.js +62 -0
- package/build/server/User/User.js +206 -0
- package/build/server/User/router.js +366 -0
- package/build/server/lib/Database.js +39 -0
- package/build/server/lib/Errors.js +6 -0
- package/build/server/lib/IPCBridge.js +128 -0
- package/build/server/lib/Log.js +31 -0
- package/build/server/lib/accumulatedThrottle.js +16 -0
- package/build/server/lib/bcrypt.js +23 -0
- package/build/server/lib/cli.js +131 -0
- package/build/server/lib/getCdgName.js +18 -0
- package/build/server/lib/getFolders.js +8 -0
- package/build/server/lib/getHotMiddleware.js +22 -0
- package/build/server/lib/getIPAddress.js +14 -0
- package/build/server/lib/getPermutations.js +17 -0
- package/build/server/lib/getWindowsDrives.js +17 -0
- package/build/server/lib/parseCookie.js +13 -0
- package/build/server/lib/pushQueuesAndLibrary.js +22 -0
- package/{server → build/server}/lib/schemas/001-initial-schema.sql +26 -26
- package/build/server/lib/schemas/004-paths-rooms-data.sql +7 -0
- package/build/server/lib/schemas/005-roles.sql +32 -0
- package/build/server/lib/util.js +39 -0
- package/build/server/main.js +124 -0
- package/build/server/scannerWorker.js +59 -0
- package/build/server/serverWorker.js +219 -0
- package/build/server/socket.js +134 -0
- package/build/server/watcherWorker.js +51 -0
- package/build/shared/actionTypes.js +113 -0
- package/build/shared/types.js +1 -0
- package/package.json +111 -86
- package/build/267.4be526e3a94d53aeceae.js +0 -1
- package/build/591.4be526e3a94d53aeceae.js +0 -1
- package/build/598.4be526e3a94d53aeceae.js +0 -1
- package/build/799.4be526e3a94d53aeceae.js +0 -1
- package/build/845.4be526e3a94d53aeceae.js +0 -1
- package/build/main.4be526e3a94d53aeceae.css +0 -2034
- package/build/main.4be526e3a94d53aeceae.js +0 -1
- package/server/Library/Library.js +0 -340
- package/server/Library/index.js +0 -3
- package/server/Library/ipc.js +0 -18
- package/server/Library/router.js +0 -27
- package/server/Library/socket.js +0 -47
- package/server/Media/Media.js +0 -207
- package/server/Media/index.js +0 -3
- package/server/Media/ipc.js +0 -19
- package/server/Media/router.js +0 -99
- package/server/Player/socket.js +0 -78
- package/server/Prefs/Prefs.js +0 -165
- package/server/Prefs/index.js +0 -3
- package/server/Prefs/router.js +0 -124
- package/server/Prefs/socket.js +0 -68
- package/server/Queue/Queue.js +0 -208
- package/server/Queue/index.js +0 -3
- package/server/Queue/socket.js +0 -99
- package/server/Rooms/Rooms.js +0 -114
- package/server/Rooms/index.js +0 -3
- package/server/Rooms/router.js +0 -146
- package/server/Scanner/FileScanner/FileScanner.js +0 -225
- package/server/Scanner/FileScanner/getConfig.js +0 -35
- package/server/Scanner/FileScanner/getFiles.js +0 -63
- package/server/Scanner/FileScanner/index.js +0 -3
- package/server/Scanner/MetaParser/MetaParser.js +0 -49
- package/server/Scanner/MetaParser/defaultMiddleware.js +0 -197
- package/server/Scanner/MetaParser/index.js +0 -3
- package/server/Scanner/Scanner.js +0 -33
- package/server/User/User.js +0 -139
- package/server/User/index.js +0 -3
- package/server/User/router.js +0 -442
- package/server/lib/Database.js +0 -55
- package/server/lib/IPCBridge.js +0 -115
- package/server/lib/Log.js +0 -71
- package/server/lib/bcrypt.js +0 -24
- package/server/lib/cli.js +0 -136
- package/server/lib/electron.js +0 -81
- package/server/lib/getCdgName.js +0 -20
- package/server/lib/getDevMiddleware.js +0 -51
- package/server/lib/getFolders.js +0 -10
- package/server/lib/getHotMiddleware.js +0 -27
- package/server/lib/getIPAddress.js +0 -16
- package/server/lib/getPermutations.js +0 -21
- package/server/lib/getWindowsDrives.js +0 -30
- package/server/lib/parseCookie.js +0 -12
- package/server/lib/pushQueuesAndLibrary.js +0 -29
- package/server/main.js +0 -135
- package/server/scannerWorker.js +0 -58
- package/server/serverWorker.js +0 -242
- package/server/socket.js +0 -173
- package/shared/actionTypes.js +0 -103
- /package/build/{7ce9eb3fe454f54745a4.woff2 → client/7ce9eb3fe454f54745a4.woff2} +0 -0
- /package/build/{598.4be526e3a94d53aeceae.css → client/851.83b0127845c2fa8729fe.css} +0 -0
- /package/build/{a35814dd9eb496e3d7cc.woff2 → client/a35814dd9eb496e3d7cc.woff2} +0 -0
- /package/build/{e419b95dccb58b362811.woff2 → client/e419b95dccb58b362811.woff2} +0 -0
- /package/{server → build/server}/lib/schemas/002-replaygain.sql +0 -0
- /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;
|