@vex-chat/spire 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -38
- package/dist/ClientManager.d.ts +1 -3
- package/dist/ClientManager.js +6 -48
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +25 -5
- package/dist/Database.js +54 -16
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -3
- package/dist/Spire.js +35 -73
- package/dist/Spire.js.map +1 -1
- package/dist/__tests__/Database.spec.js +0 -14
- package/dist/__tests__/Database.spec.js.map +1 -1
- package/dist/db/schema.d.ts +1 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
- package/dist/run.js +0 -1
- package/dist/run.js.map +1 -1
- package/dist/server/avatar.d.ts +1 -3
- package/dist/server/avatar.js +7 -11
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.d.ts +3 -6
- package/dist/server/errors.js +2 -28
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.d.ts +1 -2
- package/dist/server/file.js +5 -7
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +22 -33
- package/dist/server/index.js.map +1 -1
- package/dist/server/invite.d.ts +1 -2
- package/dist/server/invite.js +1 -1
- package/dist/server/invite.js.map +1 -1
- package/dist/server/rateLimit.d.ts +3 -3
- package/dist/server/rateLimit.js +6 -6
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -2
- package/dist/server/user.js +3 -5
- package/dist/server/user.js.map +1 -1
- package/dist/utils/jwtSecret.d.ts +3 -3
- package/dist/utils/jwtSecret.js +5 -6
- package/dist/utils/jwtSecret.js.map +1 -1
- package/dist/utils/loadEnv.js +1 -1
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +6 -4
- package/src/ClientManager.ts +6 -58
- package/src/Database.ts +83 -21
- package/src/Spire.ts +37 -108
- package/src/__tests__/Database.spec.ts +0 -17
- package/src/db/schema.ts +1 -0
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
- package/src/run.ts +0 -1
- package/src/server/avatar.ts +7 -14
- package/src/server/errors.ts +3 -32
- package/src/server/file.ts +5 -10
- package/src/server/index.ts +23 -37
- package/src/server/invite.ts +0 -2
- package/src/server/rateLimit.ts +6 -6
- package/src/server/user.ts +2 -6
- package/src/utils/jwtSecret.ts +5 -6
- package/src/utils/loadEnv.ts +1 -1
- package/dist/utils/createLogger.d.ts +0 -5
- package/dist/utils/createLogger.js +0 -35
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -47
package/src/Spire.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ActionToken, BaseMsg, NotifyMsg, User } from "@vex-chat/types";
|
|
2
2
|
import type { Server } from "http";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import { EventEmitter } from "events";
|
|
6
5
|
import { execSync } from "node:child_process";
|
|
@@ -30,11 +29,10 @@ import { WebSocketServer } from "ws";
|
|
|
30
29
|
import { z } from "zod/v4";
|
|
31
30
|
|
|
32
31
|
import { ClientManager } from "./ClientManager.ts";
|
|
33
|
-
import { Database,
|
|
32
|
+
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
34
33
|
import { initApp, protect } from "./server/index.ts";
|
|
35
34
|
import { authLimiter } from "./server/rateLimit.ts";
|
|
36
35
|
import { censorUser, getParam, getUser } from "./server/utils.ts";
|
|
37
|
-
import { createLogger } from "./utils/createLogger.ts";
|
|
38
36
|
import { getJwtSecret } from "./utils/jwtSecret.ts";
|
|
39
37
|
import { msgpack } from "./utils/msgpack.ts";
|
|
40
38
|
|
|
@@ -130,14 +128,6 @@ const getCommitSha = (): string => {
|
|
|
130
128
|
export interface SpireOptions {
|
|
131
129
|
apiPort?: number;
|
|
132
130
|
dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
|
|
133
|
-
logLevel?:
|
|
134
|
-
| "debug"
|
|
135
|
-
| "error"
|
|
136
|
-
| "http"
|
|
137
|
-
| "info"
|
|
138
|
-
| "silly"
|
|
139
|
-
| "verbose"
|
|
140
|
-
| "warn";
|
|
141
131
|
}
|
|
142
132
|
|
|
143
133
|
export class Spire extends EventEmitter {
|
|
@@ -151,9 +141,6 @@ export class Spire extends EventEmitter {
|
|
|
151
141
|
string,
|
|
152
142
|
{ deviceID: string; nonce: string; time: number }
|
|
153
143
|
>();
|
|
154
|
-
private log: winston.Logger;
|
|
155
|
-
private options: SpireOptions | undefined;
|
|
156
|
-
|
|
157
144
|
private queuedRequestIncrements = 0;
|
|
158
145
|
private requestsTotal = 0;
|
|
159
146
|
|
|
@@ -164,7 +151,10 @@ export class Spire extends EventEmitter {
|
|
|
164
151
|
|
|
165
152
|
private readonly startedAt = new Date();
|
|
166
153
|
private readonly version = getAppVersion();
|
|
167
|
-
private wss: WebSocketServer = new WebSocketServer({
|
|
154
|
+
private wss: WebSocketServer = new WebSocketServer({
|
|
155
|
+
maxPayload: 4096,
|
|
156
|
+
noServer: true,
|
|
157
|
+
});
|
|
168
158
|
|
|
169
159
|
constructor(SK: string, options?: SpireOptions) {
|
|
170
160
|
super();
|
|
@@ -180,17 +170,12 @@ export class Spire extends EventEmitter {
|
|
|
180
170
|
this.db = new Database(options);
|
|
181
171
|
this.db.on("ready", () => {
|
|
182
172
|
this.dbReady = true;
|
|
183
|
-
this.bootstrapRequestCounter().catch((
|
|
184
|
-
|
|
185
|
-
"Failed to load persisted request counter: " + String(err),
|
|
186
|
-
);
|
|
173
|
+
this.bootstrapRequestCounter().catch((_err: unknown) => {
|
|
174
|
+
// debugger: bootstrap request counter failed
|
|
187
175
|
});
|
|
188
176
|
});
|
|
189
177
|
|
|
190
|
-
this.log = createLogger("spire", options?.logLevel || "error");
|
|
191
178
|
this.init(options?.apiPort || 16777);
|
|
192
|
-
|
|
193
|
-
this.options = options;
|
|
194
179
|
}
|
|
195
180
|
|
|
196
181
|
public async close(): Promise<void> {
|
|
@@ -198,18 +183,9 @@ export class Spire extends EventEmitter {
|
|
|
198
183
|
ws.terminate();
|
|
199
184
|
});
|
|
200
185
|
|
|
201
|
-
this.wss.on("close", () => {
|
|
202
|
-
this.log.info("ws: closed.");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
this.server?.on("close", () => {
|
|
206
|
-
this.log.info("http: closed.");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
186
|
this.server?.close();
|
|
210
187
|
this.wss.close();
|
|
211
188
|
await this.db.close();
|
|
212
|
-
return;
|
|
213
189
|
}
|
|
214
190
|
|
|
215
191
|
private async bootstrapRequestCounter(): Promise<void> {
|
|
@@ -246,11 +222,8 @@ export class Spire extends EventEmitter {
|
|
|
246
222
|
if (!this.requestsTotalLoaded) {
|
|
247
223
|
this.queuedRequestIncrements += 1;
|
|
248
224
|
} else {
|
|
249
|
-
this.db.incrementRequestsTotal(1).catch((
|
|
250
|
-
|
|
251
|
-
"Failed to persist request counter increment: " +
|
|
252
|
-
String(err),
|
|
253
|
-
);
|
|
225
|
+
this.db.incrementRequestsTotal(1).catch((_err: unknown) => {
|
|
226
|
+
// debugger: failed to persist request counter
|
|
254
227
|
});
|
|
255
228
|
}
|
|
256
229
|
|
|
@@ -261,7 +234,6 @@ export class Spire extends EventEmitter {
|
|
|
261
234
|
initApp(
|
|
262
235
|
this.api,
|
|
263
236
|
this.db,
|
|
264
|
-
this.log,
|
|
265
237
|
this.validateToken.bind(this),
|
|
266
238
|
this.signKeys,
|
|
267
239
|
this.notify.bind(this),
|
|
@@ -269,11 +241,9 @@ export class Spire extends EventEmitter {
|
|
|
269
241
|
|
|
270
242
|
// WS auth: client sends { type: "auth", token } as first message
|
|
271
243
|
this.wss.on("connection", (ws) => {
|
|
272
|
-
this.log.info("WS connection established, waiting for auth...");
|
|
273
244
|
const AUTH_TIMEOUT = 10_000;
|
|
274
245
|
|
|
275
246
|
const timer = setTimeout(() => {
|
|
276
|
-
this.log.warn("WS auth timeout — closing.");
|
|
277
247
|
ws.close();
|
|
278
248
|
}, AUTH_TIMEOUT);
|
|
279
249
|
|
|
@@ -308,47 +278,27 @@ export class Spire extends EventEmitter {
|
|
|
308
278
|
}
|
|
309
279
|
const userDetails: User = jwtResult.data.user;
|
|
310
280
|
|
|
311
|
-
this.log.info(
|
|
312
|
-
"WS auth succeeded for " + userDetails.username,
|
|
313
|
-
);
|
|
314
|
-
|
|
315
281
|
const client = new ClientManager(
|
|
316
282
|
ws,
|
|
317
283
|
this.db,
|
|
318
284
|
this.notify.bind(this),
|
|
319
285
|
userDetails,
|
|
320
|
-
this.options,
|
|
321
286
|
);
|
|
322
287
|
|
|
323
288
|
client.on("fail", () => {
|
|
324
|
-
this.log.info(
|
|
325
|
-
"Client connection is down, removing: " +
|
|
326
|
-
client.toString(),
|
|
327
|
-
);
|
|
328
289
|
if (this.clients.includes(client)) {
|
|
329
290
|
this.clients.splice(
|
|
330
291
|
this.clients.indexOf(client),
|
|
331
292
|
1,
|
|
332
293
|
);
|
|
333
294
|
}
|
|
334
|
-
this.log.info(
|
|
335
|
-
"Current authorized clients: " +
|
|
336
|
-
String(this.clients.length),
|
|
337
|
-
);
|
|
338
295
|
});
|
|
339
296
|
|
|
340
297
|
client.on("authed", () => {
|
|
341
|
-
this.log.info(
|
|
342
|
-
"New client authorized: " + client.toString(),
|
|
343
|
-
);
|
|
344
298
|
this.clients.push(client);
|
|
345
|
-
this.log.info(
|
|
346
|
-
"Current authorized clients: " +
|
|
347
|
-
String(this.clients.length),
|
|
348
|
-
);
|
|
349
299
|
});
|
|
350
|
-
} catch (
|
|
351
|
-
|
|
300
|
+
} catch (_err: unknown) {
|
|
301
|
+
// debugger: WS auth failed
|
|
352
302
|
const errMsg: BaseMsg = {
|
|
353
303
|
transmissionID: crypto.randomUUID(),
|
|
354
304
|
type: "unauthorized",
|
|
@@ -421,9 +371,7 @@ export class Spire extends EventEmitter {
|
|
|
421
371
|
}
|
|
422
372
|
|
|
423
373
|
try {
|
|
424
|
-
this.log.info("New token requested of type " + tokenType);
|
|
425
374
|
const token = this.createActionToken(scope);
|
|
426
|
-
this.log.info("New token created: " + token.key);
|
|
427
375
|
|
|
428
376
|
setTimeout(() => {
|
|
429
377
|
this.deleteActionToken(token);
|
|
@@ -441,8 +389,8 @@ export class Spire extends EventEmitter {
|
|
|
441
389
|
|
|
442
390
|
res.set("Content-Type", "application/msgpack");
|
|
443
391
|
return res.send(msgpack.encode(token));
|
|
444
|
-
} catch (
|
|
445
|
-
|
|
392
|
+
} catch (_err: unknown) {
|
|
393
|
+
// debugger: token creation failed
|
|
446
394
|
return res.sendStatus(500);
|
|
447
395
|
}
|
|
448
396
|
},
|
|
@@ -457,7 +405,6 @@ export class Spire extends EventEmitter {
|
|
|
457
405
|
res.send(
|
|
458
406
|
msgpack.encode({
|
|
459
407
|
exp: req.exp,
|
|
460
|
-
token: req.bearerToken,
|
|
461
408
|
user: req.user,
|
|
462
409
|
}),
|
|
463
410
|
);
|
|
@@ -477,7 +424,12 @@ export class Spire extends EventEmitter {
|
|
|
477
424
|
const checkDurationMs = Date.now() - started;
|
|
478
425
|
|
|
479
426
|
const ok = dbHealthy;
|
|
427
|
+
const canaryEnv = process.env["CANARY"]?.trim().toLowerCase();
|
|
480
428
|
res.json({
|
|
429
|
+
canary:
|
|
430
|
+
canaryEnv === "1" ||
|
|
431
|
+
canaryEnv === "true" ||
|
|
432
|
+
canaryEnv === "yes",
|
|
481
433
|
checkDurationMs,
|
|
482
434
|
commitSha: this.commitSha,
|
|
483
435
|
dbHealthy,
|
|
@@ -532,12 +484,11 @@ export class Spire extends EventEmitter {
|
|
|
532
484
|
this.deviceChallenges.delete(challengeID);
|
|
533
485
|
}, DEVICE_CHALLENGE_EXPIRY);
|
|
534
486
|
|
|
535
|
-
this.log.info("Device challenge issued for " + deviceID);
|
|
536
487
|
return res.send(
|
|
537
488
|
msgpack.encode({ challenge: nonce, challengeID }),
|
|
538
489
|
);
|
|
539
|
-
} catch (
|
|
540
|
-
|
|
490
|
+
} catch (_err: unknown) {
|
|
491
|
+
// debugger: device challenge error
|
|
541
492
|
return res.sendStatus(500);
|
|
542
493
|
}
|
|
543
494
|
});
|
|
@@ -608,18 +559,11 @@ export class Spire extends EventEmitter {
|
|
|
608
559
|
getJwtSecret(),
|
|
609
560
|
{ expiresIn: DEVICE_AUTH_JWT_EXPIRY },
|
|
610
561
|
);
|
|
611
|
-
this.log.info(
|
|
612
|
-
"Device-key auth succeeded for " +
|
|
613
|
-
user.username +
|
|
614
|
-
" (device " +
|
|
615
|
-
device.deviceID +
|
|
616
|
-
")",
|
|
617
|
-
);
|
|
618
562
|
return res.send(
|
|
619
563
|
msgpack.encode({ token, user: censorUser(user) }),
|
|
620
564
|
);
|
|
621
|
-
} catch (
|
|
622
|
-
|
|
565
|
+
} catch (_err: unknown) {
|
|
566
|
+
// debugger: device verify error
|
|
623
567
|
return res.sendStatus(500);
|
|
624
568
|
}
|
|
625
569
|
});
|
|
@@ -648,8 +592,6 @@ export class Spire extends EventEmitter {
|
|
|
648
592
|
senderDeviceDetails.deviceID,
|
|
649
593
|
authorUserDetails.userID,
|
|
650
594
|
);
|
|
651
|
-
this.log.info("Received mail for " + mail.recipient);
|
|
652
|
-
|
|
653
595
|
const recipientDeviceDetails = await this.db.retrieveDevice(
|
|
654
596
|
mail.recipient,
|
|
655
597
|
);
|
|
@@ -681,21 +623,25 @@ export class Spire extends EventEmitter {
|
|
|
681
623
|
try {
|
|
682
624
|
const userEntry = await this.db.retrieveUser(username);
|
|
683
625
|
if (!userEntry) {
|
|
684
|
-
res.sendStatus(
|
|
685
|
-
this.log.warn("User does not exist.");
|
|
626
|
+
res.sendStatus(401);
|
|
686
627
|
return;
|
|
687
628
|
}
|
|
688
629
|
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
630
|
+
const { needsRehash, valid } = await verifyPassword(
|
|
631
|
+
password,
|
|
632
|
+
userEntry,
|
|
692
633
|
);
|
|
693
634
|
|
|
694
|
-
if (
|
|
635
|
+
if (!valid) {
|
|
695
636
|
res.sendStatus(401);
|
|
696
637
|
return;
|
|
697
638
|
}
|
|
698
639
|
|
|
640
|
+
if (needsRehash) {
|
|
641
|
+
const newHash = await hashPasswordArgon2(password);
|
|
642
|
+
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
643
|
+
}
|
|
644
|
+
|
|
699
645
|
const token = jwt.sign(
|
|
700
646
|
{ user: censorUser(userEntry) },
|
|
701
647
|
getJwtSecret(),
|
|
@@ -708,8 +654,8 @@ export class Spire extends EventEmitter {
|
|
|
708
654
|
res.send(
|
|
709
655
|
msgpack.encode({ token, user: censorUser(userEntry) }),
|
|
710
656
|
);
|
|
711
|
-
} catch (
|
|
712
|
-
|
|
657
|
+
} catch (_err: unknown) {
|
|
658
|
+
// debugger: auth error
|
|
713
659
|
res.sendStatus(500);
|
|
714
660
|
}
|
|
715
661
|
});
|
|
@@ -762,9 +708,6 @@ export class Spire extends EventEmitter {
|
|
|
762
708
|
"users_signkey_unique",
|
|
763
709
|
);
|
|
764
710
|
|
|
765
|
-
this.log.warn(
|
|
766
|
-
"User attempted to register duplicate account.",
|
|
767
|
-
);
|
|
768
711
|
if (usernameConflict) {
|
|
769
712
|
res.status(400).send({
|
|
770
713
|
error: "Username is already registered.",
|
|
@@ -782,16 +725,10 @@ export class Spire extends EventEmitter {
|
|
|
782
725
|
});
|
|
783
726
|
break;
|
|
784
727
|
default:
|
|
785
|
-
this.log.info(
|
|
786
|
-
"Unsupported sql error type: " +
|
|
787
|
-
String(errCode),
|
|
788
|
-
);
|
|
789
|
-
this.log.error(String(err));
|
|
790
728
|
res.sendStatus(500);
|
|
791
729
|
break;
|
|
792
730
|
}
|
|
793
731
|
} else {
|
|
794
|
-
this.log.info("Registration success.");
|
|
795
732
|
if (!user) {
|
|
796
733
|
res.sendStatus(500);
|
|
797
734
|
return;
|
|
@@ -803,15 +740,13 @@ export class Spire extends EventEmitter {
|
|
|
803
740
|
error: "Invalid or no token supplied.",
|
|
804
741
|
});
|
|
805
742
|
}
|
|
806
|
-
} catch (
|
|
807
|
-
|
|
743
|
+
} catch (_err: unknown) {
|
|
744
|
+
// debugger: registration error
|
|
808
745
|
res.sendStatus(500);
|
|
809
746
|
}
|
|
810
747
|
});
|
|
811
748
|
|
|
812
|
-
this.server = this.api.listen(apiPort
|
|
813
|
-
this.log.info("API started on port " + String(apiPort));
|
|
814
|
-
});
|
|
749
|
+
this.server = this.api.listen(apiPort);
|
|
815
750
|
|
|
816
751
|
// Accept all WS upgrades — auth happens post-connection.
|
|
817
752
|
this.server.on("upgrade", (req, socket, head) => {
|
|
@@ -854,7 +789,6 @@ export class Spire extends EventEmitter {
|
|
|
854
789
|
}
|
|
855
790
|
|
|
856
791
|
private validateToken(key: string, scope: TokenScopes): boolean {
|
|
857
|
-
this.log.info("Validating token: " + key);
|
|
858
792
|
for (const rKey of this.actionTokens) {
|
|
859
793
|
if (rKey.key === key) {
|
|
860
794
|
if (rKey.scope !== scope) {
|
|
@@ -862,17 +796,12 @@ export class Spire extends EventEmitter {
|
|
|
862
796
|
}
|
|
863
797
|
|
|
864
798
|
const age = Date.now() - new Date(rKey.time).getTime();
|
|
865
|
-
this.log.info("Token found, " + String(age) + " ms old.");
|
|
866
799
|
if (age < TOKEN_EXPIRY) {
|
|
867
|
-
this.log.info("Token is valid.");
|
|
868
800
|
this.deleteActionToken(rKey);
|
|
869
801
|
return true;
|
|
870
|
-
} else {
|
|
871
|
-
this.log.info("Token is expired.");
|
|
872
802
|
}
|
|
873
803
|
}
|
|
874
804
|
}
|
|
875
|
-
this.log.info("Token not found.");
|
|
876
805
|
return false;
|
|
877
806
|
}
|
|
878
807
|
}
|
|
@@ -5,7 +5,6 @@ import { XUtils } from "@vex-chat/crypto";
|
|
|
5
5
|
|
|
6
6
|
import * as uuid from "uuid";
|
|
7
7
|
import { describe, expect, it, vi } from "vitest";
|
|
8
|
-
import winston from "winston";
|
|
9
8
|
|
|
10
9
|
import { Database } from "../Database.ts";
|
|
11
10
|
|
|
@@ -27,19 +26,6 @@ vi.mock("uuid", () => ({
|
|
|
27
26
|
validate: () => true,
|
|
28
27
|
}));
|
|
29
28
|
|
|
30
|
-
/** Winston logger stub — Database.close() calls `.info`, and `{}` breaks that. */
|
|
31
|
-
function silentLogger(): winston.Logger {
|
|
32
|
-
const noop = vi.fn();
|
|
33
|
-
return {
|
|
34
|
-
debug: noop,
|
|
35
|
-
error: noop,
|
|
36
|
-
info: noop,
|
|
37
|
-
log: noop,
|
|
38
|
-
verbose: noop,
|
|
39
|
-
warn: noop,
|
|
40
|
-
} as unknown as winston.Logger;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
29
|
describe("Database", () => {
|
|
44
30
|
// Reusable test data
|
|
45
31
|
const keyID = "de459e05-aa63-4dfa-97b4-ed43d5c7a5f7";
|
|
@@ -80,9 +66,6 @@ describe("Database", () => {
|
|
|
80
66
|
expect.assertions(1);
|
|
81
67
|
|
|
82
68
|
vi.mocked(uuid.v4).mockReturnValueOnce(keyID);
|
|
83
|
-
vi.spyOn(winston, "createLogger").mockReturnValueOnce(
|
|
84
|
-
silentLogger(),
|
|
85
|
-
);
|
|
86
69
|
|
|
87
70
|
const provider = new Database(options);
|
|
88
71
|
await new Promise<void>((resolve, reject) => {
|
package/src/db/schema.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Kysely, sql } from "kysely";
|
|
2
|
+
|
|
3
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
4
|
+
await db.schema.alterTable("users").dropColumn("hashAlgo").execute();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
8
|
+
await db.schema
|
|
9
|
+
.alterTable("users")
|
|
10
|
+
.addColumn("hashAlgo", "varchar(16)", (cb) =>
|
|
11
|
+
cb.defaultTo("pbkdf2").notNull(),
|
|
12
|
+
)
|
|
13
|
+
.execute();
|
|
14
|
+
|
|
15
|
+
await sql`UPDATE users SET hashAlgo = 'pbkdf2' WHERE hashAlgo = 'pbkdf2'`.execute(
|
|
16
|
+
db,
|
|
17
|
+
);
|
|
18
|
+
}
|
package/src/run.ts
CHANGED
package/src/server/avatar.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import type { Database } from "../Database.ts";
|
|
2
|
-
import type winston from "winston";
|
|
3
|
-
|
|
4
1
|
import * as fs from "node:fs";
|
|
5
2
|
import * as fsp from "node:fs/promises";
|
|
6
3
|
|
|
@@ -20,7 +17,7 @@ import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
|
20
17
|
|
|
21
18
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
22
19
|
|
|
23
|
-
export const getAvatarRouter = (
|
|
20
|
+
export const getAvatarRouter = () => {
|
|
24
21
|
const router = express.Router();
|
|
25
22
|
|
|
26
23
|
router.get("/:userID", async (req, res) => {
|
|
@@ -39,8 +36,8 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
39
36
|
res.set("Cache-control", "public, max-age=31536000");
|
|
40
37
|
|
|
41
38
|
const stream = fs.createReadStream(filePath);
|
|
42
|
-
stream.on("error", (
|
|
43
|
-
|
|
39
|
+
stream.on("error", (_err) => {
|
|
40
|
+
// debugger: avatar stream read error
|
|
44
41
|
res.sendStatus(500);
|
|
45
42
|
});
|
|
46
43
|
stream.pipe(res);
|
|
@@ -65,7 +62,6 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
65
62
|
}
|
|
66
63
|
|
|
67
64
|
if (!payload.file) {
|
|
68
|
-
log.warn("MISSING FILE");
|
|
69
65
|
res.sendStatus(400);
|
|
70
66
|
return;
|
|
71
67
|
}
|
|
@@ -84,10 +80,9 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
84
80
|
try {
|
|
85
81
|
// write the file to disk
|
|
86
82
|
await fsp.writeFile("avatars/" + userDetails.userID, buf);
|
|
87
|
-
log.info("Wrote new avatar " + userDetails.userID);
|
|
88
83
|
res.sendStatus(200);
|
|
89
|
-
} catch (
|
|
90
|
-
|
|
84
|
+
} catch (_err: unknown) {
|
|
85
|
+
// debugger: avatar write failed
|
|
91
86
|
res.sendStatus(500);
|
|
92
87
|
}
|
|
93
88
|
});
|
|
@@ -107,7 +102,6 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
if (!req.file) {
|
|
110
|
-
log.warn("MISSING FILE");
|
|
111
105
|
res.sendStatus(400);
|
|
112
106
|
return;
|
|
113
107
|
}
|
|
@@ -128,10 +122,9 @@ export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
|
128
122
|
"avatars/" + userDetails.userID,
|
|
129
123
|
req.file.buffer,
|
|
130
124
|
);
|
|
131
|
-
log.info("Wrote new avatar " + userDetails.userID);
|
|
132
125
|
res.sendStatus(200);
|
|
133
|
-
} catch (
|
|
134
|
-
|
|
126
|
+
} catch (_err: unknown) {
|
|
127
|
+
// debugger: avatar write failed
|
|
135
128
|
res.sendStatus(500);
|
|
136
129
|
}
|
|
137
130
|
},
|
package/src/server/errors.ts
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
* (which include database internals, file paths, and stack traces)
|
|
10
10
|
* back to the client. The new pattern: throw `AppError(status, msg)`
|
|
11
11
|
* or let an unknown error propagate, and the single 4-arg middleware
|
|
12
|
-
* below converts it into a JSON response with a generic message
|
|
13
|
-
* while logging the full internals server-side via winston.
|
|
12
|
+
* below converts it into a JSON response with a generic message.
|
|
14
13
|
*
|
|
15
14
|
* - **CWE-79 / CWE-116 — Exception text reinterpreted as HTML.** Express's
|
|
16
15
|
* default finalhandler sends thrown `Error` objects as
|
|
@@ -27,7 +26,6 @@
|
|
|
27
26
|
* `express-async-errors` shim needed.
|
|
28
27
|
*/
|
|
29
28
|
import type { ErrorRequestHandler } from "express";
|
|
30
|
-
import type winston from "winston";
|
|
31
29
|
|
|
32
30
|
import { randomUUID } from "node:crypto";
|
|
33
31
|
|
|
@@ -39,8 +37,7 @@ import { ZodError } from "zod/v4";
|
|
|
39
37
|
* - `status` — HTTP status code (400, 401, 403, 404, 409, etc.)
|
|
40
38
|
* - `message` — client-safe message, MUST NOT contain request data
|
|
41
39
|
* (route params, body fields, query strings). Anything operator-
|
|
42
|
-
* only (database errors, file paths)
|
|
43
|
-
* in this string.
|
|
40
|
+
* only (database errors, file paths) should not appear in this string.
|
|
44
41
|
*
|
|
45
42
|
* Anything that isn't an `AppError` (raw `Error`, `TypeError`, a
|
|
46
43
|
* rejected promise from a DB query, etc.) is treated by the central
|
|
@@ -68,8 +65,7 @@ export class AppError extends Error {
|
|
|
68
65
|
* api.use(errorHandler(log));
|
|
69
66
|
*/
|
|
70
67
|
export const errorHandler =
|
|
71
|
-
(
|
|
72
|
-
(err, req, res, _next) => {
|
|
68
|
+
(): ErrorRequestHandler => (err, _req, res, _next) => {
|
|
73
69
|
// If headers already went out there's nothing safe to do except
|
|
74
70
|
// let Express's default handler close the socket.
|
|
75
71
|
if (res.headersSent) {
|
|
@@ -95,31 +91,6 @@ export const errorHandler =
|
|
|
95
91
|
clientMessage = err.message;
|
|
96
92
|
}
|
|
97
93
|
|
|
98
|
-
// Log the full internals server-side. `err instanceof Error` also
|
|
99
|
-
// catches AppError (extends Error), so we get stacks and messages
|
|
100
|
-
// for every branch in the logs.
|
|
101
|
-
if (err instanceof Error) {
|
|
102
|
-
log.error("request failed", {
|
|
103
|
-
error: {
|
|
104
|
-
message: err.message,
|
|
105
|
-
name: err.name,
|
|
106
|
-
stack: err.stack,
|
|
107
|
-
},
|
|
108
|
-
method: req.method,
|
|
109
|
-
path: req.path,
|
|
110
|
-
requestId,
|
|
111
|
-
status,
|
|
112
|
-
});
|
|
113
|
-
} else {
|
|
114
|
-
log.error("request failed", {
|
|
115
|
-
error: String(err),
|
|
116
|
-
method: req.method,
|
|
117
|
-
path: req.path,
|
|
118
|
-
requestId,
|
|
119
|
-
status,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
94
|
// ALWAYS JSON — prevents the exception-text-as-HTML XSS vector.
|
|
124
95
|
res.status(status)
|
|
125
96
|
.type("application/json")
|
package/src/server/file.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Database } from "../Database.ts";
|
|
2
2
|
import type { FileSQL } from "@vex-chat/types";
|
|
3
|
-
import type winston from "winston";
|
|
4
3
|
|
|
5
4
|
import * as fs from "node:fs";
|
|
6
5
|
import * as fsp from "node:fs/promises";
|
|
@@ -23,7 +22,7 @@ import { protect } from "./index.ts";
|
|
|
23
22
|
|
|
24
23
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
25
24
|
|
|
26
|
-
export const getFileRouter = (db: Database
|
|
25
|
+
export const getFileRouter = (db: Database) => {
|
|
27
26
|
const router = express.Router();
|
|
28
27
|
|
|
29
28
|
router.get("/:id", protect, async (req, res) => {
|
|
@@ -37,8 +36,8 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
37
36
|
res.sendStatus(404);
|
|
38
37
|
} else {
|
|
39
38
|
const stream = fs.createReadStream("./files/" + entry.fileID);
|
|
40
|
-
stream.on("error", (
|
|
41
|
-
|
|
39
|
+
stream.on("error", (_err) => {
|
|
40
|
+
// debugger: file stream read error
|
|
42
41
|
res.sendStatus(500);
|
|
43
42
|
});
|
|
44
43
|
stream.pipe(res);
|
|
@@ -105,12 +104,10 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
105
104
|
const newFile: FileSQL = {
|
|
106
105
|
fileID: crypto.randomUUID(),
|
|
107
106
|
nonce: payload.nonce,
|
|
108
|
-
owner:
|
|
107
|
+
owner: deviceDetails.deviceID,
|
|
109
108
|
};
|
|
110
109
|
|
|
111
110
|
await fsp.writeFile("files/" + newFile.fileID, buf);
|
|
112
|
-
log.info("Wrote new file " + newFile.fileID);
|
|
113
|
-
|
|
114
111
|
await db.createFile(newFile);
|
|
115
112
|
res.send(msgpack.encode(newFile));
|
|
116
113
|
});
|
|
@@ -157,12 +154,10 @@ export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
|
157
154
|
const newFile: FileSQL = {
|
|
158
155
|
fileID: crypto.randomUUID(),
|
|
159
156
|
nonce: payload.nonce,
|
|
160
|
-
owner:
|
|
157
|
+
owner: deviceDetails.deviceID,
|
|
161
158
|
};
|
|
162
159
|
|
|
163
160
|
await fsp.writeFile("files/" + newFile.fileID, req.file.buffer);
|
|
164
|
-
log.info("Wrote new file " + newFile.fileID);
|
|
165
|
-
|
|
166
161
|
await db.createFile(newFile);
|
|
167
162
|
res.send(msgpack.encode(newFile));
|
|
168
163
|
},
|