@vex-chat/spire 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +12 -11
  3. package/dist/Database.js +161 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +154 -71
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +30 -4
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/errors.js +8 -0
  17. package/dist/server/errors.js.map +1 -1
  18. package/dist/server/file.js +35 -12
  19. package/dist/server/file.js.map +1 -1
  20. package/dist/server/index.d.ts +8 -0
  21. package/dist/server/index.js +157 -41
  22. package/dist/server/index.js.map +1 -1
  23. package/dist/server/passkey.js +41 -26
  24. package/dist/server/passkey.js.map +1 -1
  25. package/dist/server/passkeyDevices.js +1 -0
  26. package/dist/server/passkeyDevices.js.map +1 -1
  27. package/dist/server/password.d.ts +7 -0
  28. package/dist/server/password.js +58 -0
  29. package/dist/server/password.js.map +1 -0
  30. package/dist/server/permissions.d.ts +1 -0
  31. package/dist/server/permissions.js +11 -2
  32. package/dist/server/permissions.js.map +1 -1
  33. package/dist/server/rateLimit.d.ts +11 -0
  34. package/dist/server/rateLimit.js +43 -4
  35. package/dist/server/rateLimit.js.map +1 -1
  36. package/dist/server/user.d.ts +1 -1
  37. package/dist/server/user.js +40 -9
  38. package/dist/server/user.js.map +1 -1
  39. package/dist/types/express.d.ts +3 -4
  40. package/dist/types/express.js.map +1 -1
  41. package/dist/utils/authJwt.d.ts +8 -0
  42. package/dist/utils/authJwt.js +25 -0
  43. package/dist/utils/authJwt.js.map +1 -0
  44. package/dist/utils/loadEnv.js +4 -0
  45. package/dist/utils/loadEnv.js.map +1 -1
  46. package/dist/utils/preKeySignature.d.ts +1 -1
  47. package/dist/utils/preKeySignature.js +7 -11
  48. package/dist/utils/preKeySignature.js.map +1 -1
  49. package/package.json +7 -7
  50. package/src/Database.ts +211 -152
  51. package/src/Spire.ts +411 -284
  52. package/src/__tests__/Database.spec.ts +126 -3
  53. package/src/__tests__/connectAuth.spec.ts +146 -4
  54. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  55. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  56. package/src/__tests__/passkeys.spec.ts +7 -2
  57. package/src/__tests__/password.spec.ts +223 -0
  58. package/src/__tests__/permissions.spec.ts +50 -0
  59. package/src/__tests__/preKeySignature.spec.ts +20 -3
  60. package/src/db/schema.ts +0 -2
  61. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  62. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  63. package/src/server/avatar.ts +29 -4
  64. package/src/server/errors.ts +7 -0
  65. package/src/server/file.ts +34 -13
  66. package/src/server/index.ts +286 -111
  67. package/src/server/passkey.ts +51 -28
  68. package/src/server/passkeyDevices.ts +1 -0
  69. package/src/server/password.ts +96 -0
  70. package/src/server/permissions.ts +20 -2
  71. package/src/server/rateLimit.ts +47 -4
  72. package/src/server/user.ts +44 -9
  73. package/src/types/express.ts +3 -4
  74. package/src/utils/authJwt.ts +34 -0
  75. package/src/utils/loadEnv.ts +4 -0
  76. package/src/utils/preKeySignature.ts +12 -15
  77. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  78. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  79. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
@@ -13,12 +13,16 @@ import * as fsp from "node:fs/promises";
13
13
  import express from "express";
14
14
 
15
15
  import { type KeyPair, XUtils } from "@vex-chat/crypto";
16
- import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
16
+ import {
17
+ MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
18
+ PreKeysWSSchema,
19
+ TokenScopes,
20
+ UserSchema,
21
+ } from "@vex-chat/types";
17
22
 
18
23
  import cors from "cors";
19
24
  import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
20
25
  import helmet from "helmet";
21
- import jwt from "jsonwebtoken";
22
26
  import multer from "multer";
23
27
  import parseDuration from "parse-duration";
24
28
  import { stringify as uuidStringify } from "uuid";
@@ -26,7 +30,7 @@ import { z } from "zod/v4";
26
30
 
27
31
  import { POWER_LEVELS } from "../ClientManager.ts";
28
32
  import { JWT_EXPIRY } from "../Spire.ts";
29
- import { getJwtSecret } from "../utils/jwtSecret.ts";
33
+ import { signAuthJwt, verifyAuthJwt } from "../utils/authJwt.ts";
30
34
  import { msgpack } from "../utils/msgpack.ts";
31
35
  import { verifyPreKeyWsSignature } from "../utils/preKeySignature.ts";
32
36
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
@@ -41,12 +45,14 @@ import { getInviteRouter } from "./invite.ts";
41
45
  import { setupDocs } from "./openapi.ts";
42
46
  import { getPasskeyRouter } from "./passkey.ts";
43
47
  import { getPasskeyDeviceRouter } from "./passkeyDevices.ts";
48
+ import { getPasswordRouter } from "./password.ts";
44
49
  import {
50
+ canDeletePermission,
45
51
  hasAnyPermission,
46
52
  hasPermission,
47
53
  userHasPermission,
48
54
  } from "./permissions.ts";
49
- import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
55
+ import { globalLimiter, keyBundleLimiter, uploadLimiter } from "./rateLimit.ts";
50
56
  import { getUserRouter } from "./user.ts";
51
57
  import { censorUser, getParam, getUser } from "./utils.ts";
52
58
  import { getWellKnownRouter } from "./wellKnown.ts";
@@ -64,30 +70,46 @@ export const ALLOWED_IMAGE_TYPES = [
64
70
 
65
71
  // ── Zod schemas for trust-boundary validation ──────────────────────────
66
72
  const invitePayload = z.object({
67
- duration: z.string().min(1),
68
- serverID: z.string().min(1),
73
+ duration: z.string().min(1).max(32),
74
+ serverID: z.string().min(1).max(128),
69
75
  });
70
76
 
71
77
  const channelPayload = z.object({
72
78
  name: z.string().min(1).max(255),
73
79
  });
74
80
 
75
- const deviceListPayload = z.array(z.string());
81
+ const deviceListPayload = z.array(z.string().min(1).max(128)).max(256);
76
82
 
77
83
  const connectPayload = z.object({
78
- signed: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
84
+ signed: z.custom<Uint8Array>(
85
+ (val) => val instanceof Uint8Array && val.byteLength <= 16_384,
86
+ ),
79
87
  });
80
88
 
81
89
  const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
82
90
 
83
91
  const emojiPayload = z.object({
84
- file: z.string().optional(),
85
- name: z.string().min(1),
86
- signed: z.string().optional(),
92
+ file: z.string().max(350_000).optional(),
93
+ name: z.string().trim().min(1).max(64),
94
+ signed: z.string().max(8192).optional(),
95
+ });
96
+ const emojiUpload = multer({
97
+ limits: { fields: 3, files: 1, fileSize: 256_000, parts: 4 },
87
98
  });
88
99
 
100
+ const DEVELOPMENT_CORS_ORIGINS: Array<RegExp | string> = [
101
+ /^https?:\/\/localhost(?::\d{1,5})?$/,
102
+ /^https?:\/\/127\.0\.0\.1(?::\d{1,5})?$/,
103
+ /^https?:\/\/\[::1\](?::\d{1,5})?$/,
104
+ "capacitor://localhost",
105
+ "http://tauri.localhost",
106
+ "https://tauri.localhost",
107
+ "tauri://localhost",
108
+ ];
109
+
89
110
  const jwtUserPayload = z.object({
90
111
  exp: z.number().optional(),
112
+ scope: z.literal("user"),
91
113
  user: UserSchema,
92
114
  });
93
115
 
@@ -100,6 +122,7 @@ const jwtDevicePayload = z.object({
100
122
  owner: z.string(),
101
123
  signKey: z.string(),
102
124
  }),
125
+ scope: z.literal("device"),
103
126
  });
104
127
 
105
128
  const jwtPasskeyPayload = z.object({
@@ -121,7 +144,7 @@ const checkAuth: express.RequestHandler = (req, _res, next) => {
121
144
  const token = extractBearer(req);
122
145
  if (token) {
123
146
  try {
124
- const result = jwt.verify(token, getJwtSecret());
147
+ const result = verifyAuthJwt(token);
125
148
  // Passkey-scoped JWTs share the bearer slot with regular
126
149
  // user JWTs but carry a `scope: "passkey"` discriminator
127
150
  // and a `passkey` claim. Populate `req.passkey` and the
@@ -158,7 +181,7 @@ export function createCheckDevice(
158
181
  const token = req.headers["x-device-token"];
159
182
  if (typeof token === "string" && token) {
160
183
  try {
161
- const result = jwt.verify(token, getJwtSecret());
184
+ const result = verifyAuthJwt(token);
162
185
  const parsed = jwtDevicePayload.safeParse(result);
163
186
  if (parsed.success) {
164
187
  const device = await currentTokenDevice(
@@ -180,6 +203,29 @@ export function createCheckDevice(
180
203
  };
181
204
  }
182
205
 
206
+ /**
207
+ * Revalidate the credential behind a passkey-scoped bearer on every request.
208
+ * Passkey JWTs are short-lived, but removing a credential must revoke its
209
+ * recovery and account-administration access immediately.
210
+ */
211
+ export function createCheckPasskey(
212
+ db: Pick<Database, "retrievePasskeyInternal">,
213
+ ): express.RequestHandler {
214
+ return async (req, _res, next) => {
215
+ if (req.passkey) {
216
+ const passkey = await db.retrievePasskeyInternal(
217
+ req.passkey.passkeyID,
218
+ );
219
+ if (!passkey || !req.user || passkey.userID !== req.user.userID) {
220
+ delete req.bearerToken;
221
+ delete req.passkey;
222
+ delete req.user;
223
+ }
224
+ }
225
+ next();
226
+ };
227
+ }
228
+
183
229
  async function currentTokenDevice(
184
230
  db: Pick<Database, "retrieveDevice">,
185
231
  tokenDevice: Device,
@@ -192,7 +238,7 @@ async function currentTokenDevice(
192
238
  }
193
239
 
194
240
  export const protect: express.RequestHandler = (req, res, next) => {
195
- if (!req.user) {
241
+ if (!req.user || req.passkey) {
196
242
  res.sendStatus(401);
197
243
  return;
198
244
  }
@@ -200,6 +246,15 @@ export const protect: express.RequestHandler = (req, res, next) => {
200
246
  next();
201
247
  };
202
248
 
249
+ /** Require either an approved device or a fresh passkey session. */
250
+ export const protectAnyAuth: express.RequestHandler = (req, res, next) => {
251
+ if (!req.user || (!req.device && !req.passkey)) {
252
+ res.sendStatus(401);
253
+ return;
254
+ }
255
+ next();
256
+ };
257
+
203
258
  /**
204
259
  * Restrict a route to passkey-scoped JWTs (used by the parallel
205
260
  * `/user/:id/passkey/devices/...` admin/recovery routes). A
@@ -250,13 +305,19 @@ export const initApp = (
250
305
  disconnectDevices?: (deviceIDs: string[]) => void,
251
306
  ) => {
252
307
  // INIT ROUTERS
253
- const userRouter = getUserRouter(db, tokenValidator, notify);
308
+ const userRouter = getUserRouter(
309
+ db,
310
+ tokenValidator,
311
+ notify,
312
+ disconnectDevices,
313
+ );
254
314
  const fileRouter = getFileRouter(db);
255
315
  const avatarRouter = getAvatarRouter();
256
316
  const billingRouter = getBillingRouter(db, notify);
257
317
  const entitlementRouter = getEntitlementRouter(db, notify);
258
318
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
259
319
  const passkeyRouter = getPasskeyRouter(db);
320
+ const passwordRouter = getPasswordRouter(db);
260
321
  const passkeyDeviceRouter = getPasskeyDeviceRouter(
261
322
  db,
262
323
  notify,
@@ -277,6 +338,17 @@ export const initApp = (
277
338
  // spends any cycles on body parsing, helmet, or auth. See
278
339
  // src/server/rateLimit.ts.
279
340
  api.use(globalLimiter);
341
+ // Base64 expands a 25 MiB encrypted upload to about 33.4 MiB. Give only
342
+ // the JSON fallback route enough room for that encoded payload and its
343
+ // JSON/msgpack envelope; unrelated routes keep the tighter global cap.
344
+ api.use(
345
+ "/file/json",
346
+ express.json({ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES }),
347
+ express.raw({
348
+ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
349
+ type: "application/msgpack",
350
+ }),
351
+ );
280
352
  api.use(express.json({ limit: "20mb" }));
281
353
  api.use(
282
354
  express.raw({
@@ -310,7 +382,9 @@ export const initApp = (
310
382
  origin:
311
383
  corsOrigins.length > 0
312
384
  ? corsOrigins
313
- : true /* reflect request Origin */,
385
+ : process.env["NODE_ENV"] === "production"
386
+ ? false
387
+ : DEVELOPMENT_CORS_ORIGINS,
314
388
  }),
315
389
  );
316
390
 
@@ -323,10 +397,20 @@ export const initApp = (
323
397
 
324
398
  api.use(msgpackParser);
325
399
  api.use(checkAuth);
400
+ api.use(createCheckPasskey(db));
326
401
  api.use(createCheckDevice(db));
327
402
 
328
403
  api.get("/server/:id", protect, async (req, res) => {
329
- const server = await db.retrieveServer(getParam(req, "id"));
404
+ const serverID = getParam(req, "id");
405
+ const userDetails = getUser(req);
406
+ const permissions = await db.retrievePermissions(
407
+ userDetails.userID,
408
+ "server",
409
+ );
410
+ if (!hasAnyPermission(permissions, serverID)) {
411
+ return res.sendStatus(403);
412
+ }
413
+ const server = await db.retrieveServer(serverID);
330
414
 
331
415
  if (server) {
332
416
  return res.send(msgpack.encode(server));
@@ -337,9 +421,33 @@ export const initApp = (
337
421
 
338
422
  api.post("/server/:name", protect, async (req, res) => {
339
423
  const userDetails = getUser(req);
340
- const serverName = atob(getParam(req, "name"));
424
+ const encodedName = getParam(req, "name");
425
+ if (encodedName.length > 256) {
426
+ res.sendStatus(400);
427
+ return;
428
+ }
429
+ let decodedName: string;
430
+ try {
431
+ decodedName = atob(encodedName);
432
+ } catch {
433
+ res.sendStatus(400);
434
+ return;
435
+ }
436
+ const parsedName = z
437
+ .string()
438
+ .trim()
439
+ .min(1)
440
+ .max(100)
441
+ .safeParse(decodedName);
442
+ if (!parsedName.success) {
443
+ res.sendStatus(400);
444
+ return;
445
+ }
341
446
 
342
- const server = await db.createServer(serverName, userDetails.userID);
447
+ const server = await db.createServer(
448
+ parsedName.data,
449
+ userDetails.userID,
450
+ );
343
451
  res.send(msgpack.encode(server));
344
452
  });
345
453
 
@@ -488,7 +596,16 @@ export const initApp = (
488
596
  });
489
597
 
490
598
  api.get("/server/:serverID/emoji", protect, async (req, res) => {
491
- const rows = await db.retrieveEmojiList(getParam(req, "serverID"));
599
+ const serverID = getParam(req, "serverID");
600
+ const permissions = await db.retrievePermissions(
601
+ getUser(req).userID,
602
+ "server",
603
+ );
604
+ if (!hasAnyPermission(permissions, serverID)) {
605
+ res.sendStatus(403);
606
+ return;
607
+ }
608
+ const rows = await db.retrieveEmojiList(serverID);
492
609
  res.send(msgpack.encode(rows));
493
610
  });
494
611
 
@@ -524,7 +641,7 @@ export const initApp = (
524
641
  for (const permission of permissions) {
525
642
  if (
526
643
  permission.resourceID === channel.serverID &&
527
- permission.powerLevel > 50
644
+ permission.powerLevel >= POWER_LEVELS.DELETE
528
645
  ) {
529
646
  await db.deleteChannel(channelID);
530
647
 
@@ -552,6 +669,13 @@ export const initApp = (
552
669
  const channel = await db.retrieveChannel(getParam(req, "id"));
553
670
 
554
671
  if (channel) {
672
+ const permissions = await db.retrievePermissions(
673
+ getUser(req).userID,
674
+ "server",
675
+ );
676
+ if (!hasAnyPermission(permissions, channel.serverID)) {
677
+ return res.sendStatus(403);
678
+ }
555
679
  return res.send(msgpack.encode(channel));
556
680
  } else {
557
681
  return res.sendStatus(404);
@@ -572,19 +696,19 @@ export const initApp = (
572
696
  permToDelete.resourceType,
573
697
  );
574
698
 
575
- for (const perm of permissions) {
576
- if (
577
- perm.resourceID === permToDelete.resourceID &&
578
- (perm.userID === userDetails.userID ||
579
- (perm.powerLevel > POWER_LEVELS.DELETE &&
580
- perm.powerLevel > permToDelete.powerLevel))
581
- ) {
582
- await db.deletePermission(permToDelete.permissionID);
583
- res.sendStatus(200);
584
- return;
585
- }
699
+ if (
700
+ canDeletePermission(
701
+ permissions,
702
+ userDetails.userID,
703
+ permToDelete,
704
+ POWER_LEVELS.DELETE,
705
+ )
706
+ ) {
707
+ await db.deletePermission(permToDelete.permissionID);
708
+ res.sendStatus(200);
709
+ return;
586
710
  }
587
- res.sendStatus(401);
711
+ res.sendStatus(403);
588
712
  });
589
713
 
590
714
  api.post("/userList/:channelID", protect, async (req, res) => {
@@ -661,6 +785,10 @@ export const initApp = (
661
785
  res.sendStatus(401);
662
786
  return;
663
787
  }
788
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
789
+ res.sendStatus(403);
790
+ return;
791
+ }
664
792
  const inbox = await db.retrieveMail(deviceDetails.deviceID);
665
793
  res.send(msgpack.encode(inbox));
666
794
  });
@@ -692,10 +820,7 @@ export const initApp = (
692
820
  regKey &&
693
821
  tokenValidator(uuidStringify(regKey), TokenScopes.Connect)
694
822
  ) {
695
- const token = jwt.sign({ device }, getJwtSecret(), {
696
- expiresIn: JWT_EXPIRY,
697
- });
698
- jwt.verify(token, getJwtSecret());
823
+ const token = signAuthJwt({ device, scope: "device" }, JWT_EXPIRY);
699
824
 
700
825
  res.send(msgpack.encode({ deviceToken: token }));
701
826
  } else {
@@ -709,6 +834,10 @@ export const initApp = (
709
834
  res.sendStatus(401);
710
835
  return;
711
836
  }
837
+ if (deviceDetails.deviceID !== getParam(req, "id")) {
838
+ res.sendStatus(403);
839
+ return;
840
+ }
712
841
  const count = await db.getOTKCount(deviceDetails.deviceID);
713
842
  res.send(msgpack.encode({ count }));
714
843
  });
@@ -747,7 +876,9 @@ export const initApp = (
747
876
  res.status(400).send({ error: "Prekey deviceID mismatch." });
748
877
  return;
749
878
  }
750
- if (!(await verifyPreKeyWsSignature(preKey, device.signKey))) {
879
+ if (
880
+ !(await verifyPreKeyWsSignature(preKey, device.signKey, "signed"))
881
+ ) {
751
882
  res.status(401).send({ error: "Prekey signature invalid." });
752
883
  return;
753
884
  }
@@ -796,7 +927,11 @@ export const initApp = (
796
927
  return;
797
928
  }
798
929
  if (
799
- !(await verifyPreKeyWsSignature(submittedOTK, device.signKey))
930
+ !(await verifyPreKeyWsSignature(
931
+ submittedOTK,
932
+ device.signKey,
933
+ "one-time",
934
+ ))
800
935
  ) {
801
936
  res.status(401).send({ error: "OTK signature invalid." });
802
937
  return;
@@ -809,6 +944,18 @@ export const initApp = (
809
944
 
810
945
  api.get("/emoji/:emojiID/details", protect, async (req, res) => {
811
946
  const emoji = await db.retrieveEmoji(getParam(req, "emojiID"));
947
+ if (!emoji) {
948
+ res.sendStatus(404);
949
+ return;
950
+ }
951
+ const permissions = await db.retrievePermissions(
952
+ getUser(req).userID,
953
+ "server",
954
+ );
955
+ if (!hasAnyPermission(permissions, emoji.owner)) {
956
+ res.sendStatus(403);
957
+ return;
958
+ }
812
959
  res.send(msgpack.encode(emoji));
813
960
  });
814
961
 
@@ -818,6 +965,19 @@ export const initApp = (
818
965
  res.sendStatus(400);
819
966
  return;
820
967
  }
968
+ const emoji = await db.retrieveEmoji(safeId.data);
969
+ if (!emoji) {
970
+ res.sendStatus(404);
971
+ return;
972
+ }
973
+ const permissions = await db.retrievePermissions(
974
+ getUser(req).userID,
975
+ "server",
976
+ );
977
+ if (!hasAnyPermission(permissions, emoji.owner)) {
978
+ res.sendStatus(403);
979
+ return;
980
+ }
821
981
  const filePath = "./emoji/" + safeId.data;
822
982
  const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
823
983
  if (!typeDetails) {
@@ -835,92 +995,106 @@ export const initApp = (
835
995
  stream.pipe(res);
836
996
  });
837
997
 
838
- api.post("/emoji/:serverID/json", protect, async (req, res) => {
839
- const parsedPayload = emojiPayload.safeParse(req.body);
840
- if (!parsedPayload.success) {
841
- res.status(400).json({
842
- error: "Invalid emoji payload",
843
- issues: parsedPayload.error.issues,
844
- });
845
- return;
846
- }
847
- const payload = parsedPayload.data;
998
+ api.post(
999
+ "/emoji/:serverID/json",
1000
+ uploadLimiter,
1001
+ protect,
1002
+ async (req, res) => {
1003
+ const parsedPayload = emojiPayload.safeParse(req.body);
1004
+ if (!parsedPayload.success) {
1005
+ res.status(400).json({
1006
+ error: "Invalid emoji payload",
1007
+ issues: parsedPayload.error.issues,
1008
+ });
1009
+ return;
1010
+ }
1011
+ const payload = parsedPayload.data;
848
1012
 
849
- const userDetails = getUser(req);
850
- const device = req.device;
1013
+ const userDetails = getUser(req);
1014
+ const device = req.device;
851
1015
 
852
- if (!device) {
853
- res.sendStatus(401);
854
- return;
855
- }
1016
+ if (!device) {
1017
+ res.sendStatus(401);
1018
+ return;
1019
+ }
856
1020
 
857
- if (!payload.file) {
858
- res.sendStatus(400);
859
- return;
860
- }
1021
+ if (!payload.file) {
1022
+ res.sendStatus(400);
1023
+ return;
1024
+ }
861
1025
 
862
- const buf = Buffer.from(XUtils.decodeBase64(payload.file));
863
- const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
1026
+ let buf: Buffer;
1027
+ try {
1028
+ buf = Buffer.from(XUtils.decodeBase64(payload.file));
1029
+ } catch {
1030
+ res.status(400).send({ error: "Emoji must be valid base64." });
1031
+ return;
1032
+ }
1033
+ const serverEntry = await db.retrieveServer(
1034
+ getParam(req, "serverID"),
1035
+ );
864
1036
 
865
- const permissionList = await db.retrievePermissionsByResourceID(
866
- getParam(req, "serverID"),
867
- );
1037
+ const permissionList = await db.retrievePermissionsByResourceID(
1038
+ getParam(req, "serverID"),
1039
+ );
868
1040
 
869
- if (
870
- !userHasPermission(
871
- permissionList,
872
- userDetails.userID,
873
- POWER_LEVELS.EMOJI,
874
- )
875
- ) {
876
- res.sendStatus(401);
877
- return;
878
- }
879
- if (!serverEntry) {
880
- res.sendStatus(404);
881
- return;
882
- }
883
- if (!payload.name) {
884
- res.sendStatus(400);
885
- return;
886
- }
887
- if (Buffer.byteLength(buf) > 256000) {
888
- res.sendStatus(413);
889
- return;
890
- }
1041
+ if (
1042
+ !userHasPermission(
1043
+ permissionList,
1044
+ userDetails.userID,
1045
+ POWER_LEVELS.EMOJI,
1046
+ )
1047
+ ) {
1048
+ res.sendStatus(401);
1049
+ return;
1050
+ }
1051
+ if (!serverEntry) {
1052
+ res.sendStatus(404);
1053
+ return;
1054
+ }
1055
+ if (!payload.name) {
1056
+ res.sendStatus(400);
1057
+ return;
1058
+ }
1059
+ if (Buffer.byteLength(buf) > 256000) {
1060
+ res.sendStatus(413);
1061
+ return;
1062
+ }
891
1063
 
892
- const mimeType = await fileTypeFromBuffer(buf);
893
- if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
894
- res.status(400).send({
895
- error:
896
- "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
897
- String(mimeType?.ext),
898
- });
899
- return;
900
- }
1064
+ const mimeType = await fileTypeFromBuffer(buf);
1065
+ if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
1066
+ res.status(400).send({
1067
+ error:
1068
+ "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
1069
+ String(mimeType?.ext),
1070
+ });
1071
+ return;
1072
+ }
901
1073
 
902
- const emoji: Emoji = {
903
- emojiID: crypto.randomUUID(),
904
- name: payload.name,
905
- owner: getParam(req, "serverID"),
906
- };
1074
+ const emoji: Emoji = {
1075
+ emojiID: crypto.randomUUID(),
1076
+ name: payload.name,
1077
+ owner: getParam(req, "serverID"),
1078
+ };
907
1079
 
908
- await db.createEmoji(emoji);
1080
+ await db.createEmoji(emoji);
909
1081
 
910
- try {
911
- // write the file to disk
912
- await fsp.writeFile("emoji/" + emoji.emojiID, buf);
913
- res.send(msgpack.encode(emoji));
914
- } catch (_err: unknown) {
915
- // debugger: emoji write failed
916
- res.sendStatus(500);
917
- }
918
- });
1082
+ try {
1083
+ // write the file to disk
1084
+ await fsp.writeFile("emoji/" + emoji.emojiID, buf);
1085
+ res.send(msgpack.encode(emoji));
1086
+ } catch (_err: unknown) {
1087
+ // debugger: emoji write failed
1088
+ res.sendStatus(500);
1089
+ }
1090
+ },
1091
+ );
919
1092
 
920
1093
  api.post(
921
1094
  "/emoji/:serverID",
1095
+ uploadLimiter,
922
1096
  protect,
923
- multer().single("emoji"),
1097
+ emojiUpload.single("emoji"),
924
1098
  async (req, res) => {
925
1099
  const parsedPayload = emojiPayload.safeParse(req.body);
926
1100
  if (!parsedPayload.success) {
@@ -1017,6 +1191,7 @@ export const initApp = (
1017
1191
  api.use(entitlementRouter);
1018
1192
  api.use(passkeyRouter);
1019
1193
  api.use(passkeyDeviceRouter);
1194
+ api.use(passwordRouter);
1020
1195
 
1021
1196
  api.use("/user", userRouter);
1022
1197