@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
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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>(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
)
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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(
|
|
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 =
|
|
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 (
|
|
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(
|
|
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(
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
1013
|
+
const userDetails = getUser(req);
|
|
1014
|
+
const device = req.device;
|
|
851
1015
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1016
|
+
if (!device) {
|
|
1017
|
+
res.sendStatus(401);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
856
1020
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1021
|
+
if (!payload.file) {
|
|
1022
|
+
res.sendStatus(400);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
861
1025
|
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1037
|
+
const permissionList = await db.retrievePermissionsByResourceID(
|
|
1038
|
+
getParam(req, "serverID"),
|
|
1039
|
+
);
|
|
868
1040
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1074
|
+
const emoji: Emoji = {
|
|
1075
|
+
emojiID: crypto.randomUUID(),
|
|
1076
|
+
name: payload.name,
|
|
1077
|
+
owner: getParam(req, "serverID"),
|
|
1078
|
+
};
|
|
907
1079
|
|
|
908
|
-
|
|
1080
|
+
await db.createEmoji(emoji);
|
|
909
1081
|
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
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
|
|