@vex-chat/spire 3.0.1 → 4.1.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 +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- 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 +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.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 +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- 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/password.d.ts +7 -0
- 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/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- 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 +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- 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 +7 -2
- 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/__tests__/serverManagement.spec.ts +262 -0
- 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 +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- 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/serverIcon.ts +199 -0
- package/src/server/user.ts +44 -9
- 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/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
package/dist/server/index.js
CHANGED
|
@@ -7,18 +7,17 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as fsp from "node:fs/promises";
|
|
8
8
|
import express from "express";
|
|
9
9
|
import { XUtils } from "@vex-chat/crypto";
|
|
10
|
-
import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
|
|
10
|
+
import { MAX_FILE_UPLOAD_ENCODED_BODY_BYTES, PreKeysWSSchema, TokenScopes, UserSchema, } from "@vex-chat/types";
|
|
11
11
|
import cors from "cors";
|
|
12
12
|
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
13
13
|
import helmet from "helmet";
|
|
14
|
-
import jwt from "jsonwebtoken";
|
|
15
14
|
import multer from "multer";
|
|
16
15
|
import parseDuration from "parse-duration";
|
|
17
16
|
import { stringify as uuidStringify } from "uuid";
|
|
18
17
|
import { z } from "zod/v4";
|
|
19
18
|
import { POWER_LEVELS } from "../ClientManager.js";
|
|
20
19
|
import { JWT_EXPIRY } from "../Spire.js";
|
|
21
|
-
import {
|
|
20
|
+
import { signAuthJwt, verifyAuthJwt } from "../utils/authJwt.js";
|
|
22
21
|
import { msgpack } from "../utils/msgpack.js";
|
|
23
22
|
import { verifyPreKeyWsSignature } from "../utils/preKeySignature.js";
|
|
24
23
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.js";
|
|
@@ -32,8 +31,10 @@ import { getInviteRouter } from "./invite.js";
|
|
|
32
31
|
import { setupDocs } from "./openapi.js";
|
|
33
32
|
import { getPasskeyRouter } from "./passkey.js";
|
|
34
33
|
import { getPasskeyDeviceRouter } from "./passkeyDevices.js";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
34
|
+
import { getPasswordRouter } from "./password.js";
|
|
35
|
+
import { canDeletePermission, hasAnyPermission, hasPermission, userHasPermission, } from "./permissions.js";
|
|
36
|
+
import { globalLimiter, keyBundleLimiter, uploadLimiter } from "./rateLimit.js";
|
|
37
|
+
import { deleteServerIconFile, getServerIconRouter } from "./serverIcon.js";
|
|
37
38
|
import { getUserRouter } from "./user.js";
|
|
38
39
|
import { censorUser, getParam, getUser } from "./utils.js";
|
|
39
40
|
import { getWellKnownRouter } from "./wellKnown.js";
|
|
@@ -45,27 +46,47 @@ export const ALLOWED_IMAGE_TYPES = [
|
|
|
45
46
|
"image/gif",
|
|
46
47
|
"image/apng",
|
|
47
48
|
"image/avif",
|
|
49
|
+
"image/webp",
|
|
48
50
|
];
|
|
49
51
|
// ── Zod schemas for trust-boundary validation ──────────────────────────
|
|
50
52
|
const invitePayload = z.object({
|
|
51
|
-
duration: z.string().min(1),
|
|
52
|
-
serverID: z.string().min(1),
|
|
53
|
+
duration: z.string().min(1).max(32),
|
|
54
|
+
serverID: z.string().min(1).max(128),
|
|
53
55
|
});
|
|
54
56
|
const channelPayload = z.object({
|
|
55
|
-
name: z.string().min(1).max(
|
|
57
|
+
name: z.string().trim().min(1).max(100),
|
|
56
58
|
});
|
|
57
|
-
const
|
|
59
|
+
const serverUpdatePayload = z.object({
|
|
60
|
+
name: z.string().trim().min(1).max(100),
|
|
61
|
+
});
|
|
62
|
+
const permissionRolePayload = z.object({
|
|
63
|
+
powerLevel: z.union([z.literal(0), z.literal(50), z.literal(100)]),
|
|
64
|
+
});
|
|
65
|
+
const deviceListPayload = z.array(z.string().min(1).max(128)).max(256);
|
|
58
66
|
const connectPayload = z.object({
|
|
59
|
-
signed: z.custom((val) => val instanceof Uint8Array),
|
|
67
|
+
signed: z.custom((val) => val instanceof Uint8Array && val.byteLength <= 16_384),
|
|
60
68
|
});
|
|
61
69
|
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
62
70
|
const emojiPayload = z.object({
|
|
63
|
-
file: z.string().optional(),
|
|
64
|
-
name: z.string().min(1),
|
|
65
|
-
signed: z.string().optional(),
|
|
71
|
+
file: z.string().max(350_000).optional(),
|
|
72
|
+
name: z.string().trim().min(1).max(64),
|
|
73
|
+
signed: z.string().max(8192).optional(),
|
|
74
|
+
});
|
|
75
|
+
const emojiUpload = multer({
|
|
76
|
+
limits: { fields: 3, files: 1, fileSize: 256_000, parts: 4 },
|
|
66
77
|
});
|
|
78
|
+
const DEVELOPMENT_CORS_ORIGINS = [
|
|
79
|
+
/^https?:\/\/localhost(?::\d{1,5})?$/,
|
|
80
|
+
/^https?:\/\/127\.0\.0\.1(?::\d{1,5})?$/,
|
|
81
|
+
/^https?:\/\/\[::1\](?::\d{1,5})?$/,
|
|
82
|
+
"capacitor://localhost",
|
|
83
|
+
"http://tauri.localhost",
|
|
84
|
+
"https://tauri.localhost",
|
|
85
|
+
"tauri://localhost",
|
|
86
|
+
];
|
|
67
87
|
const jwtUserPayload = z.object({
|
|
68
88
|
exp: z.number().optional(),
|
|
89
|
+
scope: z.literal("user"),
|
|
69
90
|
user: UserSchema,
|
|
70
91
|
});
|
|
71
92
|
const jwtDevicePayload = z.object({
|
|
@@ -77,6 +98,7 @@ const jwtDevicePayload = z.object({
|
|
|
77
98
|
owner: z.string(),
|
|
78
99
|
signKey: z.string(),
|
|
79
100
|
}),
|
|
101
|
+
scope: z.literal("device"),
|
|
80
102
|
});
|
|
81
103
|
const jwtPasskeyPayload = z.object({
|
|
82
104
|
passkey: z.object({
|
|
@@ -96,7 +118,7 @@ const checkAuth = (req, _res, next) => {
|
|
|
96
118
|
const token = extractBearer(req);
|
|
97
119
|
if (token) {
|
|
98
120
|
try {
|
|
99
|
-
const result =
|
|
121
|
+
const result = verifyAuthJwt(token);
|
|
100
122
|
// Passkey-scoped JWTs share the bearer slot with regular
|
|
101
123
|
// user JWTs but carry a `scope: "passkey"` discriminator
|
|
102
124
|
// and a `passkey` claim. Populate `req.passkey` and the
|
|
@@ -132,7 +154,7 @@ export function createCheckDevice(db) {
|
|
|
132
154
|
const token = req.headers["x-device-token"];
|
|
133
155
|
if (typeof token === "string" && token) {
|
|
134
156
|
try {
|
|
135
|
-
const result =
|
|
157
|
+
const result = verifyAuthJwt(token);
|
|
136
158
|
const parsed = jwtDevicePayload.safeParse(result);
|
|
137
159
|
if (parsed.success) {
|
|
138
160
|
const device = await currentTokenDevice(db, parsed.data.device);
|
|
@@ -149,6 +171,24 @@ export function createCheckDevice(db) {
|
|
|
149
171
|
next();
|
|
150
172
|
};
|
|
151
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Revalidate the credential behind a passkey-scoped bearer on every request.
|
|
176
|
+
* Passkey JWTs are short-lived, but removing a credential must revoke its
|
|
177
|
+
* recovery and account-administration access immediately.
|
|
178
|
+
*/
|
|
179
|
+
export function createCheckPasskey(db) {
|
|
180
|
+
return async (req, _res, next) => {
|
|
181
|
+
if (req.passkey) {
|
|
182
|
+
const passkey = await db.retrievePasskeyInternal(req.passkey.passkeyID);
|
|
183
|
+
if (!passkey || !req.user || passkey.userID !== req.user.userID) {
|
|
184
|
+
delete req.bearerToken;
|
|
185
|
+
delete req.passkey;
|
|
186
|
+
delete req.user;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
next();
|
|
190
|
+
};
|
|
191
|
+
}
|
|
152
192
|
async function currentTokenDevice(db, tokenDevice) {
|
|
153
193
|
const device = await db.retrieveDevice(tokenDevice.deviceID);
|
|
154
194
|
if (!device || device.signKey !== tokenDevice.signKey) {
|
|
@@ -157,7 +197,15 @@ async function currentTokenDevice(db, tokenDevice) {
|
|
|
157
197
|
return device;
|
|
158
198
|
}
|
|
159
199
|
export const protect = (req, res, next) => {
|
|
160
|
-
if (!req.user) {
|
|
200
|
+
if (!req.user || req.passkey) {
|
|
201
|
+
res.sendStatus(401);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
next();
|
|
205
|
+
};
|
|
206
|
+
/** Require either an approved device or a fresh passkey session. */
|
|
207
|
+
export const protectAnyAuth = (req, res, next) => {
|
|
208
|
+
if (!req.user || (!req.device && !req.passkey)) {
|
|
161
209
|
res.sendStatus(401);
|
|
162
210
|
return;
|
|
163
211
|
}
|
|
@@ -189,21 +237,33 @@ export const msgpackParser = (req, res, next) => {
|
|
|
189
237
|
}
|
|
190
238
|
next();
|
|
191
239
|
};
|
|
192
|
-
const directories = ["files", "avatars"];
|
|
240
|
+
const directories = ["files", "avatars", "server-icons"];
|
|
193
241
|
for (const dir of directories) {
|
|
194
242
|
if (!fs.existsSync(dir)) {
|
|
195
243
|
fs.mkdirSync(dir);
|
|
196
244
|
}
|
|
197
245
|
}
|
|
198
246
|
export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDevices) => {
|
|
247
|
+
const notifyServerChange = async (serverID, additionalUserIDs = []) => {
|
|
248
|
+
const affectedUsers = await db.retrieveAffectedUsers(serverID);
|
|
249
|
+
const userIDs = new Set([
|
|
250
|
+
...affectedUsers.map((user) => user.userID),
|
|
251
|
+
...additionalUserIDs,
|
|
252
|
+
]);
|
|
253
|
+
for (const userID of userIDs) {
|
|
254
|
+
notify(userID, "serverChange", crypto.randomUUID(), serverID);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
199
257
|
// INIT ROUTERS
|
|
200
|
-
const userRouter = getUserRouter(db, tokenValidator, notify);
|
|
258
|
+
const userRouter = getUserRouter(db, tokenValidator, notify, disconnectDevices);
|
|
201
259
|
const fileRouter = getFileRouter(db);
|
|
202
260
|
const avatarRouter = getAvatarRouter();
|
|
203
261
|
const billingRouter = getBillingRouter(db, notify);
|
|
204
262
|
const entitlementRouter = getEntitlementRouter(db, notify);
|
|
205
263
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
206
264
|
const passkeyRouter = getPasskeyRouter(db);
|
|
265
|
+
const passwordRouter = getPasswordRouter(db);
|
|
266
|
+
const serverIconRouter = getServerIconRouter(db, notifyServerChange);
|
|
207
267
|
const passkeyDeviceRouter = getPasskeyDeviceRouter(db, notify, disconnectDevices);
|
|
208
268
|
// MIDDLEWARE
|
|
209
269
|
// WebAuthn well-known association files (AASA, assetlinks.json)
|
|
@@ -217,6 +277,13 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
217
277
|
// spends any cycles on body parsing, helmet, or auth. See
|
|
218
278
|
// src/server/rateLimit.ts.
|
|
219
279
|
api.use(globalLimiter);
|
|
280
|
+
// Base64 expands a 25 MiB encrypted upload to about 33.4 MiB. Give only
|
|
281
|
+
// the JSON fallback route enough room for that encoded payload and its
|
|
282
|
+
// JSON/msgpack envelope; unrelated routes keep the tighter global cap.
|
|
283
|
+
api.use("/file/json", express.json({ limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES }), express.raw({
|
|
284
|
+
limit: MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
|
|
285
|
+
type: "application/msgpack",
|
|
286
|
+
}));
|
|
220
287
|
api.use(express.json({ limit: "20mb" }));
|
|
221
288
|
api.use(express.raw({
|
|
222
289
|
limit: "20mb",
|
|
@@ -245,7 +312,9 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
245
312
|
],
|
|
246
313
|
origin: corsOrigins.length > 0
|
|
247
314
|
? corsOrigins
|
|
248
|
-
:
|
|
315
|
+
: process.env["NODE_ENV"] === "production"
|
|
316
|
+
? false
|
|
317
|
+
: DEVELOPMENT_CORS_ORIGINS,
|
|
249
318
|
}));
|
|
250
319
|
api.use(helmet());
|
|
251
320
|
// Browser bridge used by the CLI to run WebAuthn ceremonies at the same
|
|
@@ -254,9 +323,16 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
254
323
|
api.use(getCliPasskeyPageRouter());
|
|
255
324
|
api.use(msgpackParser);
|
|
256
325
|
api.use(checkAuth);
|
|
326
|
+
api.use(createCheckPasskey(db));
|
|
257
327
|
api.use(createCheckDevice(db));
|
|
258
328
|
api.get("/server/:id", protect, async (req, res) => {
|
|
259
|
-
const
|
|
329
|
+
const serverID = getParam(req, "id");
|
|
330
|
+
const userDetails = getUser(req);
|
|
331
|
+
const permissions = await db.retrievePermissions(userDetails.userID, "server");
|
|
332
|
+
if (!hasAnyPermission(permissions, serverID)) {
|
|
333
|
+
return res.sendStatus(403);
|
|
334
|
+
}
|
|
335
|
+
const server = await db.retrieveServer(serverID);
|
|
260
336
|
if (server) {
|
|
261
337
|
return res.send(msgpack.encode(server));
|
|
262
338
|
}
|
|
@@ -264,10 +340,69 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
264
340
|
return res.sendStatus(404);
|
|
265
341
|
}
|
|
266
342
|
});
|
|
343
|
+
api.patch("/server/:id", protect, async (req, res) => {
|
|
344
|
+
const parsed = serverUpdatePayload.safeParse(req.body);
|
|
345
|
+
if (!parsed.success) {
|
|
346
|
+
res.status(400).json({
|
|
347
|
+
error: "Invalid server update",
|
|
348
|
+
issues: parsed.error.issues,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const serverID = getParam(req, "id");
|
|
353
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
354
|
+
if (!hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
|
|
355
|
+
res.sendStatus(403);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const server = await db.updateServer(serverID, parsed.data);
|
|
359
|
+
if (!server) {
|
|
360
|
+
res.sendStatus(404);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await notifyServerChange(serverID);
|
|
364
|
+
res.send(msgpack.encode(server));
|
|
365
|
+
});
|
|
366
|
+
api.post("/servers", protect, async (req, res) => {
|
|
367
|
+
const parsed = serverUpdatePayload.safeParse(req.body);
|
|
368
|
+
if (!parsed.success) {
|
|
369
|
+
res.status(400).json({
|
|
370
|
+
error: "Invalid server",
|
|
371
|
+
issues: parsed.error.issues,
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const server = await db.createServer(parsed.data.name, getUser(req).userID);
|
|
376
|
+
res.send(msgpack.encode(server));
|
|
377
|
+
});
|
|
378
|
+
// Compatibility route for clients released before server names moved into
|
|
379
|
+
// the request body. New clients use POST /servers so Unicode names work.
|
|
267
380
|
api.post("/server/:name", protect, async (req, res) => {
|
|
268
381
|
const userDetails = getUser(req);
|
|
269
|
-
const
|
|
270
|
-
|
|
382
|
+
const encodedName = getParam(req, "name");
|
|
383
|
+
if (encodedName.length > 256) {
|
|
384
|
+
res.sendStatus(400);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
let decodedName;
|
|
388
|
+
try {
|
|
389
|
+
decodedName = atob(encodedName);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
res.sendStatus(400);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const parsedName = z
|
|
396
|
+
.string()
|
|
397
|
+
.trim()
|
|
398
|
+
.min(1)
|
|
399
|
+
.max(100)
|
|
400
|
+
.safeParse(decodedName);
|
|
401
|
+
if (!parsedName.success) {
|
|
402
|
+
res.sendStatus(400);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const server = await db.createServer(parsedName.data, userDetails.userID);
|
|
271
406
|
res.send(msgpack.encode(server));
|
|
272
407
|
});
|
|
273
408
|
api.post("/server/:serverID/invites", protect, async (req, res) => {
|
|
@@ -315,11 +450,20 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
315
450
|
const serverID = getParam(req, "id");
|
|
316
451
|
const permissions = await db.retrievePermissions(userDetails.userID, "server");
|
|
317
452
|
if (hasPermission(permissions, serverID, POWER_LEVELS.DELETE)) {
|
|
453
|
+
const server = await db.retrieveServer(serverID);
|
|
454
|
+
if (!server) {
|
|
455
|
+
res.sendStatus(404);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const affectedUsers = await db.retrieveAffectedUsers(serverID);
|
|
318
459
|
await db.deleteServer(serverID);
|
|
460
|
+
if (server.icon)
|
|
461
|
+
await deleteServerIconFile(server.icon);
|
|
462
|
+
await notifyServerChange(serverID, affectedUsers.map((user) => user.userID));
|
|
319
463
|
res.sendStatus(200);
|
|
320
464
|
return;
|
|
321
465
|
}
|
|
322
|
-
res.sendStatus(
|
|
466
|
+
res.sendStatus(403);
|
|
323
467
|
});
|
|
324
468
|
api.post("/server/:id/channels", protect, async (req, res) => {
|
|
325
469
|
const userDetails = getUser(req);
|
|
@@ -338,14 +482,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
338
482
|
if (hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
|
|
339
483
|
const channel = await db.createChannel(name, serverID);
|
|
340
484
|
res.send(msgpack.encode(channel));
|
|
341
|
-
|
|
342
|
-
// tell everyone about server change
|
|
343
|
-
for (const user of affectedUsers) {
|
|
344
|
-
notify(user.userID, "serverChange", crypto.randomUUID(), serverID);
|
|
345
|
-
}
|
|
485
|
+
await notifyServerChange(serverID);
|
|
346
486
|
return;
|
|
347
487
|
}
|
|
348
|
-
res.sendStatus(
|
|
488
|
+
res.sendStatus(403);
|
|
349
489
|
});
|
|
350
490
|
api.get("/server/:id/channels", protect, async (req, res) => {
|
|
351
491
|
const serverID = getParam(req, "id");
|
|
@@ -359,7 +499,13 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
359
499
|
res.sendStatus(401);
|
|
360
500
|
});
|
|
361
501
|
api.get("/server/:serverID/emoji", protect, async (req, res) => {
|
|
362
|
-
const
|
|
502
|
+
const serverID = getParam(req, "serverID");
|
|
503
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
504
|
+
if (!hasAnyPermission(permissions, serverID)) {
|
|
505
|
+
res.sendStatus(403);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const rows = await db.retrieveEmojiList(serverID);
|
|
363
509
|
res.send(msgpack.encode(rows));
|
|
364
510
|
});
|
|
365
511
|
api.get("/server/:serverID/permissions", protect, async (req, res) => {
|
|
@@ -373,6 +519,34 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
373
519
|
}
|
|
374
520
|
res.send(msgpack.encode(permissions));
|
|
375
521
|
});
|
|
522
|
+
api.patch("/channel/:id", protect, async (req, res) => {
|
|
523
|
+
const parsed = channelPayload.safeParse(req.body);
|
|
524
|
+
if (!parsed.success) {
|
|
525
|
+
res.status(400).json({
|
|
526
|
+
error: "Invalid channel update",
|
|
527
|
+
issues: parsed.error.issues,
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const channelID = getParam(req, "id");
|
|
532
|
+
const channel = await db.retrieveChannel(channelID);
|
|
533
|
+
if (!channel) {
|
|
534
|
+
res.sendStatus(404);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
538
|
+
if (!hasPermission(permissions, channel.serverID, POWER_LEVELS.DELETE)) {
|
|
539
|
+
res.sendStatus(403);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const updated = await db.updateChannel(channelID, parsed.data.name);
|
|
543
|
+
if (!updated) {
|
|
544
|
+
res.sendStatus(404);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
await notifyServerChange(channel.serverID);
|
|
548
|
+
res.send(msgpack.encode(updated));
|
|
549
|
+
});
|
|
376
550
|
api.delete("/channel/:id", protect, async (req, res) => {
|
|
377
551
|
const channelID = getParam(req, "id");
|
|
378
552
|
const userDetails = getUser(req);
|
|
@@ -384,28 +558,72 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
384
558
|
const permissions = await db.retrievePermissions(userDetails.userID, "server");
|
|
385
559
|
for (const permission of permissions) {
|
|
386
560
|
if (permission.resourceID === channel.serverID &&
|
|
387
|
-
permission.powerLevel
|
|
388
|
-
await db.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
561
|
+
permission.powerLevel >= POWER_LEVELS.DELETE) {
|
|
562
|
+
const deleted = await db.deleteChannelIfNotLast(channelID);
|
|
563
|
+
if (!deleted) {
|
|
564
|
+
res.status(409).json({
|
|
565
|
+
error: "A server must have at least one channel.",
|
|
566
|
+
});
|
|
567
|
+
return;
|
|
394
568
|
}
|
|
569
|
+
await notifyServerChange(channel.serverID);
|
|
570
|
+
res.sendStatus(200);
|
|
395
571
|
return;
|
|
396
572
|
}
|
|
397
573
|
}
|
|
398
|
-
res.sendStatus(
|
|
574
|
+
res.sendStatus(403);
|
|
399
575
|
});
|
|
400
576
|
api.get("/channel/:id", protect, async (req, res) => {
|
|
401
577
|
const channel = await db.retrieveChannel(getParam(req, "id"));
|
|
402
578
|
if (channel) {
|
|
579
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
580
|
+
if (!hasAnyPermission(permissions, channel.serverID)) {
|
|
581
|
+
return res.sendStatus(403);
|
|
582
|
+
}
|
|
403
583
|
return res.send(msgpack.encode(channel));
|
|
404
584
|
}
|
|
405
585
|
else {
|
|
406
586
|
return res.sendStatus(404);
|
|
407
587
|
}
|
|
408
588
|
});
|
|
589
|
+
api.patch("/permission/:permissionID", protect, async (req, res) => {
|
|
590
|
+
const parsed = permissionRolePayload.safeParse(req.body);
|
|
591
|
+
if (!parsed.success) {
|
|
592
|
+
res.status(400).json({
|
|
593
|
+
error: "Invalid role",
|
|
594
|
+
issues: parsed.error.issues,
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const target = await db.retrievePermission(getParam(req, "permissionID"));
|
|
599
|
+
if (!target) {
|
|
600
|
+
res.sendStatus(404);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (target.resourceType !== "server") {
|
|
604
|
+
res.sendStatus(400);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const actor = getUser(req);
|
|
608
|
+
if (target.userID === actor.userID) {
|
|
609
|
+
res.status(400).json({
|
|
610
|
+
error: "Use another owner to change your role.",
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const actorPermissions = await db.retrievePermissions(actor.userID, "server");
|
|
615
|
+
if (!hasPermission(actorPermissions, target.resourceID, 100)) {
|
|
616
|
+
res.sendStatus(403);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const updated = await db.updatePermissionPowerLevel(target.permissionID, parsed.data.powerLevel);
|
|
620
|
+
if (!updated) {
|
|
621
|
+
res.sendStatus(404);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
await notifyServerChange(target.resourceID);
|
|
625
|
+
res.send(msgpack.encode(updated));
|
|
626
|
+
});
|
|
409
627
|
api.delete("/permission/:permissionID", protect, async (req, res) => {
|
|
410
628
|
const permissionID = getParam(req, "permissionID");
|
|
411
629
|
const userDetails = getUser(req);
|
|
@@ -415,17 +633,29 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
415
633
|
return;
|
|
416
634
|
}
|
|
417
635
|
const permissions = await db.retrievePermissions(userDetails.userID, permToDelete.resourceType);
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
636
|
+
if (canDeletePermission(permissions, userDetails.userID, permToDelete, POWER_LEVELS.DELETE)) {
|
|
637
|
+
if (permToDelete.resourceType === "server" &&
|
|
638
|
+
permToDelete.powerLevel >= 100) {
|
|
639
|
+
const resourcePermissions = await db.retrievePermissionsByResourceID(permToDelete.resourceID);
|
|
640
|
+
const hasAnotherOwner = resourcePermissions.some((permission) => permission.permissionID !== permToDelete.permissionID &&
|
|
641
|
+
permission.powerLevel >= 100);
|
|
642
|
+
if (!hasAnotherOwner) {
|
|
643
|
+
res.status(409).json({
|
|
644
|
+
error: "Delete the server instead of leaving it without an owner.",
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
await db.deletePermission(permToDelete.permissionID);
|
|
650
|
+
if (permToDelete.resourceType === "server") {
|
|
651
|
+
await notifyServerChange(permToDelete.resourceID, [
|
|
652
|
+
permToDelete.userID,
|
|
653
|
+
]);
|
|
426
654
|
}
|
|
655
|
+
res.sendStatus(200);
|
|
656
|
+
return;
|
|
427
657
|
}
|
|
428
|
-
res.sendStatus(
|
|
658
|
+
res.sendStatus(403);
|
|
429
659
|
});
|
|
430
660
|
api.post("/userList/:channelID", protect, async (req, res) => {
|
|
431
661
|
const userDetails = getUser(req);
|
|
@@ -486,6 +716,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
486
716
|
res.sendStatus(401);
|
|
487
717
|
return;
|
|
488
718
|
}
|
|
719
|
+
if (deviceDetails.deviceID !== getParam(req, "id")) {
|
|
720
|
+
res.sendStatus(403);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
489
723
|
const inbox = await db.retrieveMail(deviceDetails.deviceID);
|
|
490
724
|
res.send(msgpack.encode(inbox));
|
|
491
725
|
});
|
|
@@ -511,10 +745,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
511
745
|
const regKey = await spireXSignOpenAsync(signed, XUtils.decodeHex(device.signKey));
|
|
512
746
|
if (regKey &&
|
|
513
747
|
tokenValidator(uuidStringify(regKey), TokenScopes.Connect)) {
|
|
514
|
-
const token =
|
|
515
|
-
expiresIn: JWT_EXPIRY,
|
|
516
|
-
});
|
|
517
|
-
jwt.verify(token, getJwtSecret());
|
|
748
|
+
const token = signAuthJwt({ device, scope: "device" }, JWT_EXPIRY);
|
|
518
749
|
res.send(msgpack.encode({ deviceToken: token }));
|
|
519
750
|
}
|
|
520
751
|
else {
|
|
@@ -527,6 +758,10 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
527
758
|
res.sendStatus(401);
|
|
528
759
|
return;
|
|
529
760
|
}
|
|
761
|
+
if (deviceDetails.deviceID !== getParam(req, "id")) {
|
|
762
|
+
res.sendStatus(403);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
530
765
|
const count = await db.getOTKCount(deviceDetails.deviceID);
|
|
531
766
|
res.send(msgpack.encode({ count }));
|
|
532
767
|
});
|
|
@@ -560,7 +795,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
560
795
|
res.status(400).send({ error: "Prekey deviceID mismatch." });
|
|
561
796
|
return;
|
|
562
797
|
}
|
|
563
|
-
if (!(await verifyPreKeyWsSignature(preKey, device.signKey))) {
|
|
798
|
+
if (!(await verifyPreKeyWsSignature(preKey, device.signKey, "signed"))) {
|
|
564
799
|
res.status(401).send({ error: "Prekey signature invalid." });
|
|
565
800
|
return;
|
|
566
801
|
}
|
|
@@ -601,7 +836,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
601
836
|
res.status(400).send({ error: "OTK deviceID mismatch." });
|
|
602
837
|
return;
|
|
603
838
|
}
|
|
604
|
-
if (!(await verifyPreKeyWsSignature(submittedOTK, device.signKey))) {
|
|
839
|
+
if (!(await verifyPreKeyWsSignature(submittedOTK, device.signKey, "one-time"))) {
|
|
605
840
|
res.status(401).send({ error: "OTK signature invalid." });
|
|
606
841
|
return;
|
|
607
842
|
}
|
|
@@ -611,6 +846,15 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
611
846
|
});
|
|
612
847
|
api.get("/emoji/:emojiID/details", protect, async (req, res) => {
|
|
613
848
|
const emoji = await db.retrieveEmoji(getParam(req, "emojiID"));
|
|
849
|
+
if (!emoji) {
|
|
850
|
+
res.sendStatus(404);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
854
|
+
if (!hasAnyPermission(permissions, emoji.owner)) {
|
|
855
|
+
res.sendStatus(403);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
614
858
|
res.send(msgpack.encode(emoji));
|
|
615
859
|
});
|
|
616
860
|
api.get("/emoji/:emojiID", protect, async (req, res) => {
|
|
@@ -619,6 +863,16 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
619
863
|
res.sendStatus(400);
|
|
620
864
|
return;
|
|
621
865
|
}
|
|
866
|
+
const emoji = await db.retrieveEmoji(safeId.data);
|
|
867
|
+
if (!emoji) {
|
|
868
|
+
res.sendStatus(404);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const permissions = await db.retrievePermissions(getUser(req).userID, "server");
|
|
872
|
+
if (!hasAnyPermission(permissions, emoji.owner)) {
|
|
873
|
+
res.sendStatus(403);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
622
876
|
const filePath = "./emoji/" + safeId.data;
|
|
623
877
|
const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
|
|
624
878
|
if (!typeDetails) {
|
|
@@ -634,7 +888,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
634
888
|
});
|
|
635
889
|
stream.pipe(res);
|
|
636
890
|
});
|
|
637
|
-
api.post("/emoji/:serverID/json", protect, async (req, res) => {
|
|
891
|
+
api.post("/emoji/:serverID/json", uploadLimiter, protect, async (req, res) => {
|
|
638
892
|
const parsedPayload = emojiPayload.safeParse(req.body);
|
|
639
893
|
if (!parsedPayload.success) {
|
|
640
894
|
res.status(400).json({
|
|
@@ -654,7 +908,14 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
654
908
|
res.sendStatus(400);
|
|
655
909
|
return;
|
|
656
910
|
}
|
|
657
|
-
|
|
911
|
+
let buf;
|
|
912
|
+
try {
|
|
913
|
+
buf = Buffer.from(XUtils.decodeBase64(payload.file));
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
res.status(400).send({ error: "Emoji must be valid base64." });
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
658
919
|
const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
|
|
659
920
|
const permissionList = await db.retrievePermissionsByResourceID(getParam(req, "serverID"));
|
|
660
921
|
if (!userHasPermission(permissionList, userDetails.userID, POWER_LEVELS.EMOJI)) {
|
|
@@ -697,7 +958,7 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
697
958
|
res.sendStatus(500);
|
|
698
959
|
}
|
|
699
960
|
});
|
|
700
|
-
api.post("/emoji/:serverID", protect,
|
|
961
|
+
api.post("/emoji/:serverID", uploadLimiter, protect, emojiUpload.single("emoji"), async (req, res) => {
|
|
701
962
|
const parsedPayload = emojiPayload.safeParse(req.body);
|
|
702
963
|
if (!parsedPayload.success) {
|
|
703
964
|
res.status(400).json({
|
|
@@ -774,9 +1035,11 @@ export const initApp = (api, db, tokenValidator, signKeys, notify, disconnectDev
|
|
|
774
1035
|
api.use(entitlementRouter);
|
|
775
1036
|
api.use(passkeyRouter);
|
|
776
1037
|
api.use(passkeyDeviceRouter);
|
|
1038
|
+
api.use(passwordRouter);
|
|
777
1039
|
api.use("/user", userRouter);
|
|
778
1040
|
api.use("/file", fileRouter);
|
|
779
1041
|
api.use("/avatar", avatarRouter);
|
|
1042
|
+
api.use("/server-icon", serverIconRouter);
|
|
780
1043
|
api.use("/invite", inviteRouter);
|
|
781
1044
|
setupDocs(api);
|
|
782
1045
|
// Central error handler MUST be last. Handles both thrown AppErrors
|