@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
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Database } from "../Database.ts";
8
+ import type { AddressInfo } from "node:net";
9
+
10
+ import express from "express";
11
+
12
+ import {
13
+ afterAll,
14
+ afterEach,
15
+ beforeAll,
16
+ describe,
17
+ expect,
18
+ it,
19
+ vi,
20
+ } from "vitest";
21
+
22
+ import { hashPasswordArgon2, verifyPassword } from "../Database.ts";
23
+ import { errorHandler } from "../server/errors.ts";
24
+ import { getPasswordRouter } from "../server/password.ts";
25
+
26
+ const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
27
+ const otherUserID = "93ce482b-a0f2-4f6e-b1df-3aed61073552";
28
+ const passkeyID = "passkey-1";
29
+ const initialPassword = "This is the original password";
30
+ const replacementPassword = "This is the replacement password";
31
+ const servers: Array<{ close: () => void }> = [];
32
+ const originalDisableRateLimits = process.env["SPIRE_DISABLE_RATE_LIMITS"];
33
+ let initialPasswordHash = "";
34
+
35
+ beforeAll(async () => {
36
+ process.env["SPIRE_DISABLE_RATE_LIMITS"] = "true";
37
+ initialPasswordHash = await hashPasswordArgon2(initialPassword);
38
+ });
39
+
40
+ afterEach(() => {
41
+ for (const server of servers.splice(0)) server.close();
42
+ });
43
+
44
+ afterAll(() => {
45
+ if (originalDisableRateLimits === undefined) {
46
+ delete process.env["SPIRE_DISABLE_RATE_LIMITS"];
47
+ } else {
48
+ process.env["SPIRE_DISABLE_RATE_LIMITS"] = originalDisableRateLimits;
49
+ }
50
+ });
51
+
52
+ describe("verifyPassword", () => {
53
+ it.each(["not-an-argon2-hash", "pbkdf2$100000$salt$hash"])(
54
+ "fails closed for an unsupported stored hash: %s",
55
+ async (passwordHash) => {
56
+ await expect(
57
+ verifyPassword(initialPassword, { passwordHash }),
58
+ ).resolves.toEqual({ needsRehash: false, valid: false });
59
+ },
60
+ );
61
+ });
62
+
63
+ describe("PATCH /user/:id/password", () => {
64
+ it("changes a password after approved-device and current-password proof", async () => {
65
+ const harness = await mountPasswordRouter("device");
66
+ const response = await patchPassword(harness.baseUrl, userID, {
67
+ currentPassword: initialPassword,
68
+ newPassword: replacementPassword,
69
+ });
70
+
71
+ expect(response.status).toBe(204);
72
+ expect(harness.rehashPassword).toHaveBeenCalledOnce();
73
+ await expectPassword(harness.passwordHash(), replacementPassword, true);
74
+ await expectPassword(harness.passwordHash(), initialPassword, false);
75
+ });
76
+
77
+ it("rejects missing or incorrect current-password proof", async () => {
78
+ const harness = await mountPasswordRouter("device");
79
+ const missing = await patchPassword(harness.baseUrl, userID, {
80
+ newPassword: replacementPassword,
81
+ });
82
+ const incorrect = await patchPassword(harness.baseUrl, userID, {
83
+ currentPassword: "This is definitely not the password",
84
+ newPassword: replacementPassword,
85
+ });
86
+
87
+ expect(missing.status).toBe(401);
88
+ expect(incorrect.status).toBe(401);
89
+ expect(harness.rehashPassword).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("resets a password after fresh passkey proof", async () => {
93
+ const harness = await mountPasswordRouter("passkey");
94
+ const response = await patchPassword(harness.baseUrl, userID, {
95
+ newPassword: replacementPassword,
96
+ });
97
+
98
+ expect(response.status).toBe(204);
99
+ await expectPassword(harness.passwordHash(), replacementPassword, true);
100
+ });
101
+
102
+ it("rejects passkeys that are no longer bound to the account", async () => {
103
+ const harness = await mountPasswordRouter("passkey", false);
104
+ const response = await patchPassword(harness.baseUrl, userID, {
105
+ newPassword: replacementPassword,
106
+ });
107
+
108
+ expect(response.status).toBe(401);
109
+ expect(harness.rehashPassword).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("rejects cross-account targeting, reuse, and common passwords", async () => {
113
+ const harness = await mountPasswordRouter("device");
114
+ const crossAccount = await patchPassword(harness.baseUrl, otherUserID, {
115
+ currentPassword: initialPassword,
116
+ newPassword: replacementPassword,
117
+ });
118
+ const reused = await patchPassword(harness.baseUrl, userID, {
119
+ currentPassword: initialPassword,
120
+ newPassword: initialPassword,
121
+ });
122
+ const common = await patchPassword(harness.baseUrl, userID, {
123
+ currentPassword: initialPassword,
124
+ newPassword: "passwordpassword",
125
+ });
126
+
127
+ expect(crossAccount.status).toBe(403);
128
+ expect(reused.status).toBe(409);
129
+ expect(common.status).toBe(400);
130
+ expect(harness.rehashPassword).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ async function expectPassword(
135
+ passwordHash: string,
136
+ password: string,
137
+ expected: boolean,
138
+ ): Promise<void> {
139
+ const result = await verifyPassword(password, { passwordHash });
140
+ expect(result.valid).toBe(expected);
141
+ }
142
+
143
+ async function mountPasswordRouter(
144
+ auth: "device" | "passkey",
145
+ passkeyExists = true,
146
+ ): Promise<{
147
+ baseUrl: string;
148
+ passwordHash: () => string;
149
+ rehashPassword: ReturnType<typeof vi.fn>;
150
+ }> {
151
+ let passwordHash = initialPasswordHash;
152
+ const rehashPassword = vi.fn((_userID: string, nextHash: string) => {
153
+ passwordHash = nextHash;
154
+ return Promise.resolve();
155
+ });
156
+ const db = {
157
+ rehashPassword,
158
+ retrievePasskeyInternal: vi.fn(() =>
159
+ Promise.resolve(passkeyExists ? { passkeyID, userID } : null),
160
+ ),
161
+ retrieveUser: vi.fn((identifier: string) =>
162
+ Promise.resolve(
163
+ identifier === userID
164
+ ? {
165
+ lastSeen: new Date().toISOString(),
166
+ passwordHash,
167
+ userID,
168
+ username: "alice",
169
+ }
170
+ : null,
171
+ ),
172
+ ),
173
+ } as unknown as Database;
174
+
175
+ const app = express();
176
+ app.use(express.json());
177
+ app.use((req, _res, next) => {
178
+ req.user = {
179
+ lastSeen: new Date().toISOString(),
180
+ userID,
181
+ username: "alice",
182
+ };
183
+ if (auth === "device") {
184
+ req.device = {
185
+ deleted: false,
186
+ deviceID: "device-1",
187
+ lastLogin: new Date().toISOString(),
188
+ name: "Test device",
189
+ owner: userID,
190
+ signKey: "a".repeat(64),
191
+ };
192
+ } else {
193
+ req.passkey = { passkeyID };
194
+ }
195
+ next();
196
+ });
197
+ app.use(getPasswordRouter(db));
198
+ app.use(errorHandler());
199
+
200
+ return new Promise((resolve) => {
201
+ const server = app.listen(0, "127.0.0.1", () => {
202
+ const { port } = server.address() as AddressInfo;
203
+ resolve({
204
+ baseUrl: `http://127.0.0.1:${String(port)}`,
205
+ passwordHash: () => passwordHash,
206
+ rehashPassword,
207
+ });
208
+ });
209
+ servers.push(server);
210
+ });
211
+ }
212
+
213
+ async function patchPassword(
214
+ baseUrl: string,
215
+ routeUserID: string,
216
+ body: { currentPassword?: string; newPassword: string },
217
+ ): Promise<Response> {
218
+ return fetch(`${baseUrl}/user/${routeUserID}/password`, {
219
+ body: JSON.stringify(body),
220
+ headers: { "Content-Type": "application/json" },
221
+ method: "PATCH",
222
+ });
223
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Permission } from "@vex-chat/types";
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ canDeletePermission,
13
+ hasPermission,
14
+ userHasPermission,
15
+ } from "../server/permissions.ts";
16
+
17
+ function permission(
18
+ userID: string,
19
+ powerLevel: number,
20
+ permissionID = `${userID}-${String(powerLevel)}`,
21
+ ): Permission {
22
+ return {
23
+ permissionID,
24
+ powerLevel,
25
+ resourceID: "server-a",
26
+ resourceType: "server",
27
+ userID,
28
+ };
29
+ }
30
+
31
+ describe("server permissions", () => {
32
+ it("treats a minimum power level as inclusive", () => {
33
+ const permissions = [permission("alice", 50)];
34
+ expect(hasPermission(permissions, "server-a", 50)).toBe(true);
35
+ expect(userHasPermission(permissions, "alice", 50)).toBe(true);
36
+ });
37
+
38
+ it("does not let an ordinary member remove another user's permission", () => {
39
+ const actor = permission("alice", 0);
40
+ const target = permission("bob", 0);
41
+ expect(canDeletePermission([actor], "alice", target, 50)).toBe(false);
42
+ });
43
+
44
+ it("allows self-removal and higher-power moderation", () => {
45
+ const own = permission("alice", 0);
46
+ const admin = permission("admin", 100);
47
+ expect(canDeletePermission([own], "alice", own, 50)).toBe(true);
48
+ expect(canDeletePermission([admin], "admin", own, 50)).toBe(true);
49
+ });
50
+ });
@@ -7,8 +7,7 @@
7
7
  import {
8
8
  setCryptoProfile,
9
9
  xBoxKeyPairAsync,
10
- xConstants,
11
- xEncode,
10
+ xPreKeySignaturePayload,
12
11
  xSignAsync,
13
12
  xSignKeyPair,
14
13
  XUtils,
@@ -25,7 +24,7 @@ async function makeSignedPreKey() {
25
24
  const signKeys = xSignKeyPair();
26
25
  const preKey = await xBoxKeyPairAsync();
27
26
  const signature = await xSignAsync(
28
- xEncode(xConstants.CURVE, preKey.publicKey),
27
+ xPreKeySignaturePayload(preKey.publicKey, "signed"),
29
28
  signKeys.secretKey,
30
29
  );
31
30
  return { preKey, signature, signKeys };
@@ -55,6 +54,7 @@ describe("prekey signature validation", () => {
55
54
  signature,
56
55
  },
57
56
  XUtils.encodeHex(signKeys.publicKey),
57
+ "signed",
58
58
  ),
59
59
  ).resolves.toBe(true);
60
60
  });
@@ -71,4 +71,21 @@ describe("prekey signature validation", () => {
71
71
  }),
72
72
  ).resolves.toBe(false);
73
73
  });
74
+
75
+ it("does not accept a signed prekey as a one-time prekey", async () => {
76
+ const { preKey, signature, signKeys } = await makeSignedPreKey();
77
+
78
+ await expect(
79
+ verifyPreKeyWsSignature(
80
+ {
81
+ deviceID: "device-a",
82
+ index: 1,
83
+ publicKey: preKey.publicKey,
84
+ signature,
85
+ },
86
+ XUtils.encodeHex(signKeys.publicKey),
87
+ "one-time",
88
+ ),
89
+ ).resolves.toBe(false);
90
+ });
74
91
  });
package/src/db/schema.ts CHANGED
@@ -276,10 +276,8 @@ export type ServiceMetricUpdate = Updateable<ServiceMetricsTable>;
276
276
 
277
277
  export type UserRow = Selectable<UsersTable>;
278
278
  export interface UsersTable {
279
- hashAlgo: string;
280
279
  lastSeen: string;
281
280
  passwordHash: string;
282
- passwordSalt: string;
283
281
  userID: string;
284
282
  username: string;
285
283
  }
@@ -31,11 +31,10 @@ export async function up(db: Kysely<unknown>): Promise<void> {
31
31
  await db.schema
32
32
  .createTable("users")
33
33
  .ifNotExists()
34
- .addColumn("userID", "varchar(255)", (cb) => cb.primaryKey())
35
- .addColumn("username", "varchar(255)", (cb) => cb.unique())
36
- .addColumn("passwordHash", "text")
37
- .addColumn("passwordSalt", "text")
38
- .addColumn("lastSeen", "text")
34
+ .addColumn("userID", "varchar(255)", (cb) => cb.primaryKey().notNull())
35
+ .addColumn("username", "varchar(255)", (cb) => cb.unique().notNull())
36
+ .addColumn("passwordHash", "text", (cb) => cb.notNull())
37
+ .addColumn("lastSeen", "text", (cb) => cb.notNull())
39
38
  .execute();
40
39
 
41
40
  await db.schema
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { Kysely } from "kysely";
8
+
9
+ export async function down(db: Kysely<unknown>): Promise<void> {
10
+ await db.schema.dropIndex("channels_serverID_idx").ifExists().execute();
11
+ await db.schema.dropIndex("devices_owner_deleted_idx").ifExists().execute();
12
+ await db.schema.dropIndex("permissions_user_type_idx").ifExists().execute();
13
+ }
14
+
15
+ export async function up(db: Kysely<unknown>): Promise<void> {
16
+ await db.schema
17
+ .createIndex("devices_owner_deleted_idx")
18
+ .ifNotExists()
19
+ .on("devices")
20
+ .columns(["owner", "deleted"])
21
+ .execute();
22
+ await db.schema
23
+ .createIndex("channels_serverID_idx")
24
+ .ifNotExists()
25
+ .on("channels")
26
+ .column("serverID")
27
+ .execute();
28
+ await db.schema
29
+ .createIndex("permissions_user_type_idx")
30
+ .ifNotExists()
31
+ .on("permissions")
32
+ .columns(["userID", "resourceType"])
33
+ .execute();
34
+ }
@@ -21,13 +21,20 @@ import { getParam, getUser } from "./utils.ts";
21
21
  import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
22
22
 
23
23
  const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
24
+ const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
25
+ const avatarUpload = multer({
26
+ limits: { fields: 1, files: 1, fileSize: MAX_AVATAR_BYTES, parts: 2 },
27
+ });
24
28
 
25
29
  // Avatars are public, unencrypted images. The body for the JSON path is just a
26
30
  // base64-encoded image; nothing else. Do NOT reuse FilePayloadSchema here —
27
31
  // that schema is for encrypted user-file uploads and requires nonce/owner/signed,
28
32
  // which the avatar client never sends (and shouldn't).
29
33
  const avatarJsonPayload = z.object({
30
- file: z.string().min(1),
34
+ file: z
35
+ .string()
36
+ .min(1)
37
+ .max(Math.ceil((MAX_AVATAR_BYTES * 4) / 3) + 4),
31
38
  });
32
39
 
33
40
  export const getAvatarRouter = () => {
@@ -56,7 +63,7 @@ export const getAvatarRouter = () => {
56
63
  stream.pipe(res);
57
64
  });
58
65
 
59
- router.post("/:userID/json", protect, async (req, res) => {
66
+ router.post("/:userID/json", uploadLimiter, protect, async (req, res) => {
60
67
  const parsed = avatarJsonPayload.safeParse(req.body);
61
68
  if (!parsed.success) {
62
69
  res.status(400).json({
@@ -73,8 +80,22 @@ export const getAvatarRouter = () => {
73
80
  res.sendStatus(401);
74
81
  return;
75
82
  }
83
+ if (getParam(req, "userID") !== userDetails.userID) {
84
+ res.sendStatus(403);
85
+ return;
86
+ }
76
87
 
77
- const buf = Buffer.from(XUtils.decodeBase64(payload.file));
88
+ let buf: Buffer;
89
+ try {
90
+ buf = Buffer.from(XUtils.decodeBase64(payload.file));
91
+ } catch {
92
+ res.status(400).send({ error: "Avatar must be valid base64." });
93
+ return;
94
+ }
95
+ if (buf.byteLength > MAX_AVATAR_BYTES) {
96
+ res.sendStatus(413);
97
+ return;
98
+ }
78
99
  const mimeType = await fileTypeFromBuffer(buf);
79
100
  if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
80
101
  res.status(400).send({
@@ -99,7 +120,7 @@ export const getAvatarRouter = () => {
99
120
  "/:userID",
100
121
  uploadLimiter,
101
122
  protect,
102
- multer().single("avatar"),
123
+ avatarUpload.single("avatar"),
103
124
  async (req, res) => {
104
125
  const userDetails = getUser(req);
105
126
  const deviceDetails = req.device;
@@ -108,6 +129,10 @@ export const getAvatarRouter = () => {
108
129
  res.sendStatus(401);
109
130
  return;
110
131
  }
132
+ if (getParam(req, "userID") !== userDetails.userID) {
133
+ res.sendStatus(403);
134
+ return;
135
+ }
111
136
 
112
137
  if (!req.file) {
113
138
  res.sendStatus(400);
@@ -35,6 +35,7 @@ import type { ErrorRequestHandler } from "express";
35
35
 
36
36
  import { randomUUID } from "node:crypto";
37
37
 
38
+ import multer from "multer";
38
39
  import { ZodError } from "zod/v4";
39
40
 
40
41
  /**
@@ -92,6 +93,12 @@ export const errorHandler =
92
93
  status = 400;
93
94
  clientMessage = "Validation failed";
94
95
  details = err.issues;
96
+ } else if (err instanceof multer.MulterError) {
97
+ status = err.code === "LIMIT_FILE_SIZE" ? 413 : 400;
98
+ clientMessage =
99
+ err.code === "LIMIT_FILE_SIZE"
100
+ ? "Uploaded file is too large"
101
+ : "Invalid multipart upload";
95
102
  } else if (err instanceof AppError) {
96
103
  status = err.status;
97
104
  clientMessage = err.message;
@@ -14,7 +14,7 @@ import * as path from "node:path";
14
14
  import express from "express";
15
15
 
16
16
  import { XUtils } from "@vex-chat/crypto";
17
- import { FilePayloadSchema } from "@vex-chat/types";
17
+ import { FilePayloadSchema, MAX_FILE_UPLOAD_BYTES } from "@vex-chat/types";
18
18
 
19
19
  import multer from "multer";
20
20
  import { z } from "zod/v4";
@@ -27,6 +27,14 @@ import { getParam } from "./utils.ts";
27
27
  import { protect } from "./index.ts";
28
28
 
29
29
  const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
30
+ const fileUpload = multer({
31
+ limits: {
32
+ fields: 4,
33
+ files: 1,
34
+ fileSize: MAX_FILE_UPLOAD_BYTES,
35
+ parts: 5,
36
+ },
37
+ });
30
38
 
31
39
  export const getFileRouter = (db: Database) => {
32
40
  const router = express.Router();
@@ -77,7 +85,7 @@ export const getFileRouter = (db: Database) => {
77
85
  }
78
86
  });
79
87
 
80
- router.post("/json", protect, async (req, res) => {
88
+ router.post("/json", uploadLimiter, protect, async (req, res) => {
81
89
  const deviceDetails = req.device;
82
90
 
83
91
  if (!deviceDetails) {
@@ -95,17 +103,28 @@ export const getFileRouter = (db: Database) => {
95
103
  }
96
104
  const payload = parsed.data;
97
105
 
98
- if (payload.nonce === "") {
106
+ if (!payload.file) {
99
107
  res.sendStatus(400);
100
108
  return;
101
109
  }
102
-
103
- if (!payload.file) {
104
- res.sendStatus(400);
110
+ if (payload.owner !== deviceDetails.deviceID) {
111
+ res.status(400).send({
112
+ error: "File owner must match this device.",
113
+ });
105
114
  return;
106
115
  }
107
116
 
108
- const buf = Buffer.from(XUtils.decodeBase64(payload.file));
117
+ let buf: Buffer;
118
+ try {
119
+ buf = Buffer.from(XUtils.decodeBase64(payload.file));
120
+ } catch {
121
+ res.status(400).send({ error: "File must be valid base64." });
122
+ return;
123
+ }
124
+ if (buf.byteLength > MAX_FILE_UPLOAD_BYTES) {
125
+ res.sendStatus(413);
126
+ return;
127
+ }
109
128
 
110
129
  const newFile: FileSQL = {
111
130
  fileID: crypto.randomUUID(),
@@ -120,20 +139,20 @@ export const getFileRouter = (db: Database) => {
120
139
 
121
140
  // Multipart file upload — form fields are strings from multer, not full FilePayload
122
141
  const multipartFields = z.object({
123
- nonce: z.string().min(1),
124
- owner: z.string().min(1),
142
+ nonce: z.string().regex(/^[0-9a-fA-F]{48}$/),
143
+ owner: z.string().min(1).max(128),
125
144
  });
126
145
 
127
146
  router.post(
128
147
  "/",
129
148
  uploadLimiter,
130
149
  protect,
131
- multer().single("file"),
150
+ fileUpload.single("file"),
132
151
  async (req, res) => {
133
152
  const deviceDetails = req.device;
134
153
 
135
154
  if (!deviceDetails) {
136
- res.sendStatus(400);
155
+ res.sendStatus(401);
137
156
  return;
138
157
  }
139
158
 
@@ -152,8 +171,10 @@ export const getFileRouter = (db: Database) => {
152
171
  return;
153
172
  }
154
173
 
155
- if (payload.nonce === "") {
156
- res.sendStatus(400);
174
+ if (payload.owner !== deviceDetails.deviceID) {
175
+ res.status(400).send({
176
+ error: "File owner must match this device.",
177
+ });
157
178
  return;
158
179
  }
159
180