@vex-chat/spire 3.0.0 → 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.
- package/README.md +9 -8
- package/dist/Database.d.ts +12 -11
- package/dist/Database.js +161 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -3
- package/dist/Spire.js +176 -92
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +30 -4
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +157 -41
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +41 -33
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/{passkeySecondFactor.d.ts → password.d.ts} +1 -1
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +53 -17
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +211 -152
- package/src/Spire.ts +418 -294
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +9 -54
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +29 -4
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +286 -111
- package/src/server/passkey.ts +51 -35
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/user.ts +58 -25
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- package/dist/server/passkeySecondFactor.js +0 -20
- package/dist/server/passkeySecondFactor.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
- package/src/server/passkeySecondFactor.ts +0 -27
|
@@ -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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
+
}
|
package/src/server/avatar.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/server/errors.ts
CHANGED
|
@@ -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;
|
package/src/server/file.ts
CHANGED
|
@@ -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.
|
|
106
|
+
if (!payload.file) {
|
|
99
107
|
res.sendStatus(400);
|
|
100
108
|
return;
|
|
101
109
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
150
|
+
fileUpload.single("file"),
|
|
132
151
|
async (req, res) => {
|
|
133
152
|
const deviceDetails = req.device;
|
|
134
153
|
|
|
135
154
|
if (!deviceDetails) {
|
|
136
|
-
res.sendStatus(
|
|
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.
|
|
156
|
-
res.
|
|
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
|
|