@vex-chat/spire 0.7.4 → 1.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 (202) hide show
  1. package/README.md +82 -26
  2. package/dist/ClientManager.d.ts +24 -25
  3. package/dist/ClientManager.js +232 -509
  4. package/dist/ClientManager.js.map +1 -0
  5. package/dist/Database.d.ts +49 -41
  6. package/dist/Database.js +698 -716
  7. package/dist/Database.js.map +1 -0
  8. package/dist/Spire.d.ts +23 -15
  9. package/dist/Spire.js +518 -218
  10. package/dist/Spire.js.map +1 -0
  11. package/dist/__tests__/Database.spec.js +113 -73
  12. package/dist/__tests__/Database.spec.js.map +1 -0
  13. package/dist/db/schema.d.ts +134 -0
  14. package/dist/db/schema.js +2 -0
  15. package/dist/db/schema.js.map +1 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +3 -5
  18. package/dist/index.js.map +1 -0
  19. package/dist/middleware/validate.d.ts +12 -0
  20. package/dist/middleware/validate.js +35 -0
  21. package/dist/middleware/validate.js.map +1 -0
  22. package/dist/migrations/2026-04-06_initial-schema.d.ts +3 -0
  23. package/dist/migrations/2026-04-06_initial-schema.js +192 -0
  24. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -0
  25. package/dist/run.js +26 -21
  26. package/dist/run.js.map +1 -0
  27. package/dist/server/avatar.d.ts +3 -4
  28. package/dist/server/avatar.js +85 -67
  29. package/dist/server/avatar.js.map +1 -0
  30. package/dist/server/errors.d.ts +59 -0
  31. package/dist/server/errors.js +94 -0
  32. package/dist/server/errors.js.map +1 -0
  33. package/dist/server/file.d.ts +3 -4
  34. package/dist/server/file.js +101 -61
  35. package/dist/server/file.js.map +1 -0
  36. package/dist/server/index.d.ts +9 -6
  37. package/dist/server/index.js +595 -70
  38. package/dist/server/index.js.map +1 -0
  39. package/dist/server/invite.d.ts +4 -5
  40. package/dist/server/invite.js +21 -103
  41. package/dist/server/invite.js.map +1 -0
  42. package/dist/server/openapi.d.ts +2 -0
  43. package/dist/server/openapi.js +40 -0
  44. package/dist/server/openapi.js.map +1 -0
  45. package/dist/server/permissions.d.ts +16 -0
  46. package/dist/server/permissions.js +22 -0
  47. package/dist/server/permissions.js.map +1 -0
  48. package/dist/server/rateLimit.d.ts +28 -0
  49. package/dist/server/rateLimit.js +58 -0
  50. package/dist/server/rateLimit.js.map +1 -0
  51. package/dist/server/user.d.ts +4 -7
  52. package/dist/server/user.js +66 -76
  53. package/dist/server/user.js.map +1 -0
  54. package/dist/server/utils.d.ts +35 -7
  55. package/dist/server/utils.js +50 -6
  56. package/dist/server/utils.js.map +1 -0
  57. package/dist/types/express.d.ts +20 -0
  58. package/dist/types/express.js +2 -0
  59. package/dist/types/express.js.map +1 -0
  60. package/dist/utils/createLogger.js +13 -19
  61. package/dist/utils/createLogger.js.map +1 -0
  62. package/dist/utils/createUint8UUID.js +6 -10
  63. package/dist/utils/createUint8UUID.js.map +1 -0
  64. package/dist/utils/jwtSecret.d.ts +7 -0
  65. package/dist/utils/jwtSecret.js +15 -0
  66. package/dist/utils/jwtSecret.js.map +1 -0
  67. package/dist/utils/loadEnv.js +7 -22
  68. package/dist/utils/loadEnv.js.map +1 -0
  69. package/dist/utils/msgpack.d.ts +2 -0
  70. package/dist/utils/msgpack.js +4 -0
  71. package/dist/utils/msgpack.js.map +1 -0
  72. package/package.json +91 -63
  73. package/src/ClientManager.ts +434 -0
  74. package/src/Database.ts +925 -0
  75. package/src/Spire.ts +878 -0
  76. package/src/__tests__/Database.spec.ts +167 -0
  77. package/src/ambient-modules.d.ts +1 -0
  78. package/src/db/schema.ts +165 -0
  79. package/src/index.ts +3 -0
  80. package/src/middleware/validate.ts +38 -0
  81. package/src/migrations/2026-04-06_initial-schema.ts +218 -0
  82. package/src/run.ts +37 -0
  83. package/src/server/avatar.ts +141 -0
  84. package/src/server/errors.ts +133 -0
  85. package/src/server/file.ts +172 -0
  86. package/src/server/index.ts +855 -0
  87. package/src/server/invite.ts +65 -0
  88. package/src/server/openapi.ts +51 -0
  89. package/src/server/permissions.ts +40 -0
  90. package/src/server/rateLimit.ts +86 -0
  91. package/src/server/user.ts +125 -0
  92. package/src/server/utils.ts +59 -0
  93. package/src/types/express.ts +23 -0
  94. package/src/utils/createLogger.ts +47 -0
  95. package/src/utils/createUint8UUID.ts +9 -0
  96. package/src/utils/jwtSecret.ts +16 -0
  97. package/src/utils/loadEnv.ts +15 -0
  98. package/src/utils/msgpack.ts +4 -0
  99. package/avatars/169d76cb-6e7c-4e24-8224-017673eed8ff +0 -0
  100. package/avatars/1cae8d0b-0c6b-4c73-b25c-2d2349a57122 +0 -0
  101. package/avatars/1d87bc2b-71fc-4818-8004-40d04093e5fc +0 -0
  102. package/avatars/1f2c3d62-8b4d-465a-9895-51a24caef00d +0 -0
  103. package/avatars/245ee7fc-1004-41ab-adab-a04c9ceb9d7a +0 -0
  104. package/avatars/2465c28c-bdaf-4fa2-b42a-d054f04dc39b +0 -0
  105. package/avatars/3900a674-a2dd-4996-a61d-8c29b3270f41 +0 -0
  106. package/avatars/3c3b9c77-ea50-45e7-bb25-65d6f3d2a219 +0 -0
  107. package/avatars/414a2ff4-ad2f-4a7d-aa27-3b09ad3522b2 +0 -0
  108. package/avatars/522fe504-f0ad-4ed4-9dc6-e5dc2338e531 +0 -0
  109. package/avatars/53e5eb29-e7d1-4d58-add9-d44f39f0cfb7 +0 -0
  110. package/avatars/54d3f757-1038-41c8-bfb9-efd37b6e8ebe +0 -0
  111. package/avatars/623e86d7-c49c-46f6-9b76-ca70c9dbc43b +0 -0
  112. package/avatars/66e2abae-60f5-4dd1-b9fb-297a4bedfeb0 +0 -0
  113. package/avatars/6f37980e-f6fa-4d6d-9206-24050b403f45 +0 -0
  114. package/avatars/80138ece-eb5c-4b20-817b-903d6b0f54ae +0 -0
  115. package/avatars/841c77d3-37c4-431b-be22-672888062874 +0 -0
  116. package/avatars/88051a61-2bda-4750-95a3-5fb0b4918149 +0 -0
  117. package/avatars/89540973-c421-4bf1-8b89-9f1eaa51c1b5 +0 -0
  118. package/avatars/8a802fad-8c99-4942-8f80-47cec600149c +0 -0
  119. package/avatars/90531d8a-907a-4a1a-ac45-c85e4acb0df9 +0 -0
  120. package/avatars/9b7d0da9-b8d6-4801-b128-9993f79f464a +0 -0
  121. package/avatars/9bc456f1-c4c4-48a1-b9e6-fd44dd744a72 +0 -0
  122. package/avatars/9cf878bf-7430-49ec-a47a-78f3c93793dd +0 -0
  123. package/avatars/9ee82847-6ad3-45e5-92b9-f474a6c54d96 +0 -0
  124. package/avatars/ab44c857-d81d-4c88-85db-32f9532e5376 +0 -0
  125. package/avatars/b396a8d2-dc14-48d2-aac4-3755dc637051 +0 -0
  126. package/avatars/b6ac11c5-a8b2-4e0a-995a-9c87f1e58787 +0 -0
  127. package/avatars/b79d6855-b738-434c-be32-809637e62b9b +0 -0
  128. package/avatars/bbcd0188-d6a5-48ae-90fb-be5ff30599ab +0 -0
  129. package/avatars/bc7a9e0e-4720-4a6e-a90d-c11fec94d380 +0 -0
  130. package/avatars/c1c4889f-8383-4041-8bdd-9fded4046f37 +0 -0
  131. package/avatars/c4c7203c-d93a-4749-ade2-17053acf1d2a +0 -0
  132. package/avatars/ca974a70-0a23-4668-8b80-c4304dc7f793 +0 -0
  133. package/avatars/cf119a0d-eb3f-4bed-905b-f14a876c3535 +0 -0
  134. package/avatars/d464b03d-30c2-49e3-a666-80aefa8a1b35 +0 -0
  135. package/avatars/da0eee89-82f0-4d45-ab48-7d2786b634c5 +0 -0
  136. package/avatars/de4c77a5-68e9-4bb2-b40d-d964bf377d61 +0 -0
  137. package/avatars/dea95395-7d0b-42aa-a9ed-40c7d4fb4c48 +0 -0
  138. package/avatars/edb30749-59ba-4aa2-9c52-0fb22048f4cf +0 -0
  139. package/avatars/f17c245a-af7d-445b-9365-49f7f54b1eeb +0 -0
  140. package/avatars/f1ee6a35-b262-4dbf-99f5-3d011e3b98ec +0 -0
  141. package/avatars/f802bdd0-345d-41f6-9184-0f30e1258fb3 +0 -0
  142. package/dist/migrations/20210103192527_users.d.ts +0 -3
  143. package/dist/migrations/20210103192527_users.js +0 -30
  144. package/dist/migrations/20210103193502_mail.d.ts +0 -3
  145. package/dist/migrations/20210103193502_mail.js +0 -35
  146. package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
  147. package/dist/migrations/20210103193525_preKeys.js +0 -30
  148. package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
  149. package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
  150. package/dist/migrations/20210103193615_servers.d.ts +0 -3
  151. package/dist/migrations/20210103193615_servers.js +0 -28
  152. package/dist/migrations/20210103193729_channels.d.ts +0 -3
  153. package/dist/migrations/20210103193729_channels.js +0 -28
  154. package/dist/migrations/20210103193749_permissions.d.ts +0 -3
  155. package/dist/migrations/20210103193749_permissions.js +0 -30
  156. package/dist/migrations/20210103193801_files.d.ts +0 -3
  157. package/dist/migrations/20210103193801_files.js +0 -28
  158. package/files/00ea7368-45a7-4f6a-a199-974da14be1a0 +0 -0
  159. package/files/039503d2-a170-4962-b921-c97994ba64ff +0 -0
  160. package/files/14b6fa02-4cbb-40df-be4f-a07187cb619e +0 -0
  161. package/files/15c04cb1-dc6a-4f19-aa6f-4a3b92d05bf7 +0 -0
  162. package/files/2a8d411c-8b92-4532-b84d-d64c638d6293 +0 -0
  163. package/files/37de2cd3-08a8-4044-9c37-e13386765f3d +0 -0
  164. package/files/42452029-284e-4c81-9f18-feb6ce309eed +0 -0
  165. package/files/43d09f2f-29c8-415f-8c4a-23f2a32eb79e +0 -0
  166. package/files/52992923-33ab-44a1-8118-605e9b4856a7 +0 -0
  167. package/files/53180681-36e2-49c0-8382-94dca0da09bf +0 -0
  168. package/files/5a56cd7b-1d04-4619-b60f-1e5515b9164a +0 -0
  169. package/files/5ced7676-20c4-4219-a4f2-70a25eb7eea8 +0 -0
  170. package/files/60e787ff-8ec5-444d-b963-0aaf5313b53e +0 -0
  171. package/files/67a17729-fcb7-4339-9499-1fc08fea72ca +0 -0
  172. package/files/68d09565-908d-4a67-8f09-e183f8708eb4 +0 -0
  173. package/files/70e587c5-56e8-47f0-bd36-691efcb0cc2e +0 -4
  174. package/files/7a227619-b715-4e2c-a79d-b2158d56a799 +0 -0
  175. package/files/7e3cc3ea-b706-4835-994d-65d33acaf369 +0 -0
  176. package/files/816f72a0-65dc-40c1-8862-1d22065cca3c +0 -0
  177. package/files/8bf84972-5086-4631-a752-093d7b1a098b +0 -0
  178. package/files/8c46e3bc-3f2e-441d-b8cc-17428fc3d219 +0 -0
  179. package/files/8eb83364-8826-4eee-895a-ba5cd3ab85e9 +0 -0
  180. package/files/8faefaea-14e3-49e4-ac74-9710622bfae9 +0 -0
  181. package/files/91af08c2-9dca-41f4-b6c6-b7bf33505df9 +0 -0
  182. package/files/9349dffa-35dd-49a0-a651-10b438038f95 +0 -0
  183. package/files/b0a12a41-6283-4f27-b2c8-66774991d96c +0 -0
  184. package/files/b28afb08-d18b-48a8-93c3-6a59c66d8fee +0 -0
  185. package/files/be60fe01-1578-4363-a908-41500092b77d +0 -0
  186. package/files/cf7b90e3-734a-4453-9ff3-9a331404b5ef +0 -0
  187. package/files/d1be30aa-8ff4-41c1-b360-f65922029047 +0 -0
  188. package/files/d2d31a57-a413-443c-9b97-fa9ca394ef0b +0 -0
  189. package/files/d357f223-b786-478d-a113-b53cb62acdab +0 -0
  190. package/files/d4a69ee0-05c4-4ff0-8753-624cdd0f24e9 +0 -0
  191. package/files/d57f411b-1874-4f00-a6b0-2f86de6b229d +0 -0
  192. package/files/d85aaa33-7f70-4a70-b3d2-cad667beb38c +0 -0
  193. package/files/db2fc236-dbe0-4d24-bfaf-884829fd090a +0 -0
  194. package/files/df2e20ec-6dcc-4402-94ee-7d1163f84197 +0 -0
  195. package/files/e0880ab5-6d49-4dc0-a5ec-0d3793a8a7b8 +0 -0
  196. package/files/e59ee9c6-5aea-48ea-b3d9-94a324dc2260 +0 -0
  197. package/files/e6d7aad6-4220-4ce7-85f8-89d0b1f0cc89 +0 -0
  198. package/files/f0bfc98c-3534-459d-931a-7c6e77bde153 +0 -0
  199. package/files/f8443740-050c-4714-9bf6-334783ae6ffd +0 -0
  200. package/files/fc405ae7-f9fb-455d-ac8c-0ca0d88d4115 +0 -0
  201. package/jest.config.js +0 -13
  202. package/spire.sqlite +0 -0
@@ -0,0 +1,855 @@
1
+ import type { Database } from "../Database.ts";
2
+ import type { Emoji } from "@vex-chat/types";
3
+ import type winston from "winston";
4
+
5
+ import * as fs from "node:fs";
6
+ import * as fsp from "node:fs/promises";
7
+
8
+ import express from "express";
9
+
10
+ import { XUtils } from "@vex-chat/crypto";
11
+ import { type KeyPair, xSignOpen } from "@vex-chat/crypto";
12
+ import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
13
+
14
+ import cors from "cors";
15
+ import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
16
+ import helmet from "helmet";
17
+ import jwt from "jsonwebtoken";
18
+ import morgan from "morgan";
19
+ import multer from "multer";
20
+ import parseDuration from "parse-duration";
21
+ import { stringify as uuidStringify } from "uuid";
22
+ import { z } from "zod/v4";
23
+
24
+ import { POWER_LEVELS } from "../ClientManager.ts";
25
+ import { JWT_EXPIRY } from "../Spire.ts";
26
+ import { getJwtSecret } from "../utils/jwtSecret.ts";
27
+ import { msgpack } from "../utils/msgpack.ts";
28
+
29
+ import { getAvatarRouter } from "./avatar.ts";
30
+ import { errorHandler } from "./errors.ts";
31
+ import { getFileRouter } from "./file.ts";
32
+ import { getInviteRouter } from "./invite.ts";
33
+ import { setupDocs } from "./openapi.ts";
34
+ import {
35
+ hasAnyPermission,
36
+ hasPermission,
37
+ userHasPermission,
38
+ } from "./permissions.ts";
39
+ import { globalLimiter } from "./rateLimit.ts";
40
+ import { getUserRouter } from "./user.ts";
41
+ import { censorUser, getParam, getUser } from "./utils.ts";
42
+
43
+ // expiry of regkeys
44
+ export const EXPIRY_TIME = 1000 * 60 * 5;
45
+
46
+ export const ALLOWED_IMAGE_TYPES = [
47
+ "image/jpeg",
48
+ "image/png",
49
+ "image/gif",
50
+ "image/apng",
51
+ "image/avif",
52
+ ];
53
+
54
+ // ── Zod schemas for trust-boundary validation ──────────────────────────
55
+ const invitePayload = z.object({
56
+ duration: z.string().min(1),
57
+ serverID: z.string().min(1),
58
+ });
59
+
60
+ const channelPayload = z.object({
61
+ name: z.string().min(1).max(255),
62
+ });
63
+
64
+ const deviceListPayload = z.array(z.string());
65
+
66
+ const connectPayload = z.object({
67
+ signed: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
68
+ });
69
+
70
+ const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
71
+
72
+ const emojiPayload = z.object({
73
+ file: z.string().optional(),
74
+ name: z.string().min(1),
75
+ signed: z.string().optional(),
76
+ });
77
+
78
+ const jwtUserPayload = z.object({
79
+ exp: z.number().optional(),
80
+ user: UserSchema,
81
+ });
82
+
83
+ const jwtDevicePayload = z.object({
84
+ device: z.object({
85
+ deleted: z.boolean(),
86
+ deviceID: z.string(),
87
+ lastLogin: z.string(),
88
+ name: z.string(),
89
+ owner: z.string(),
90
+ signKey: z.string(),
91
+ }),
92
+ });
93
+
94
+ /** Extract Bearer token from Authorization header. */
95
+ function extractBearer(req: express.Request): null | string {
96
+ const header = req.headers.authorization;
97
+ if (!header || !header.startsWith("Bearer ")) return null;
98
+ return header.slice(7);
99
+ }
100
+
101
+ const checkAuth: express.RequestHandler = (req, _res, next) => {
102
+ const token = extractBearer(req);
103
+ if (token) {
104
+ try {
105
+ const result = jwt.verify(token, getJwtSecret());
106
+ const parsed = jwtUserPayload.safeParse(result);
107
+ if (parsed.success) {
108
+ req.user = parsed.data.user;
109
+ if (parsed.data.exp !== undefined) {
110
+ req.exp = parsed.data.exp;
111
+ }
112
+ req.bearerToken = token;
113
+ }
114
+ } catch {
115
+ // Token verification failed — continue without auth
116
+ }
117
+ }
118
+ next();
119
+ };
120
+
121
+ const checkDevice: express.RequestHandler = (req, _res, next) => {
122
+ const token = req.headers["x-device-token"];
123
+ if (typeof token === "string" && token) {
124
+ try {
125
+ const result = jwt.verify(token, getJwtSecret());
126
+ const parsed = jwtDevicePayload.safeParse(result);
127
+ if (parsed.success) {
128
+ req.device = parsed.data.device;
129
+ }
130
+ } catch {
131
+ // Device token verification failed — continue without device
132
+ }
133
+ }
134
+ next();
135
+ };
136
+
137
+ export const protect: express.RequestHandler = (req, res, next) => {
138
+ if (!req.user) {
139
+ res.sendStatus(401);
140
+ return;
141
+ }
142
+
143
+ next();
144
+ };
145
+
146
+ export const msgpackParser: express.RequestHandler = (req, res, next) => {
147
+ if (req.is("application/msgpack")) {
148
+ try {
149
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument -- Express req.body is any; decoded body is validated by route-level Zod schemas
150
+ req.body = msgpack.decode(req.body);
151
+ } catch {
152
+ res.sendStatus(400);
153
+ return;
154
+ }
155
+ }
156
+ next();
157
+ };
158
+
159
+ const isProduction = process.env["NODE_ENV"] === "production";
160
+
161
+ const directories = ["files", "avatars"];
162
+ for (const dir of directories) {
163
+ if (!fs.existsSync(dir)) {
164
+ fs.mkdirSync(dir);
165
+ }
166
+ }
167
+
168
+ export const initApp = (
169
+ api: express.Application,
170
+ db: Database,
171
+ log: winston.Logger,
172
+ tokenValidator: (key: string, scope: TokenScopes) => boolean,
173
+ signKeys: KeyPair,
174
+ notify: (
175
+ userID: string,
176
+ event: string,
177
+ transmissionID: string,
178
+ data?: unknown,
179
+ deviceID?: string,
180
+ ) => void,
181
+ ) => {
182
+ // INIT ROUTERS
183
+ const userRouter = getUserRouter(db, log, tokenValidator);
184
+ const fileRouter = getFileRouter(db, log);
185
+ const avatarRouter = getAvatarRouter(db, log);
186
+ const inviteRouter = getInviteRouter(db, log, tokenValidator, notify);
187
+
188
+ // MIDDLEWARE
189
+ // Global per-IP rate limit is the FIRST middleware so a flooded
190
+ // source hits the limiter before Express spends any cycles on
191
+ // body parsing, helmet, or auth. See src/server/rateLimit.ts.
192
+ api.use(globalLimiter);
193
+ api.use(express.json({ limit: "20mb" }));
194
+ api.use(
195
+ express.raw({
196
+ limit: "20mb",
197
+ type: "application/msgpack",
198
+ }),
199
+ );
200
+ if (isProduction) {
201
+ api.use(helmet());
202
+ }
203
+ api.use(msgpackParser);
204
+ api.use(checkAuth);
205
+ api.use(checkDevice);
206
+
207
+ if (!jestRun()) {
208
+ api.use(morgan("dev", { stream: process.stdout }));
209
+ }
210
+
211
+ api.use(cors({ credentials: true }));
212
+
213
+ api.get("/server/:id", protect, async (req, res) => {
214
+ const server = await db.retrieveServer(getParam(req, "id"));
215
+
216
+ if (server) {
217
+ return res.send(msgpack.encode(server));
218
+ } else {
219
+ return res.sendStatus(404);
220
+ }
221
+ });
222
+
223
+ api.post("/server/:name", protect, async (req, res) => {
224
+ const userDetails = getUser(req);
225
+ const serverName = atob(getParam(req, "name"));
226
+
227
+ const server = await db.createServer(serverName, userDetails.userID);
228
+ res.send(msgpack.encode(server));
229
+ });
230
+
231
+ api.post("/server/:serverID/invites", protect, async (req, res) => {
232
+ const userDetails = getUser(req);
233
+
234
+ const parsedPayload = invitePayload.safeParse(req.body);
235
+ if (!parsedPayload.success) {
236
+ res.status(400).json({
237
+ error: "Invalid invite payload",
238
+ issues: parsedPayload.error.issues,
239
+ });
240
+ return;
241
+ }
242
+ const payload = parsedPayload.data;
243
+ const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
244
+
245
+ if (!serverEntry) {
246
+ res.sendStatus(404);
247
+ return;
248
+ }
249
+
250
+ const permissions = await db.retrievePermissions(
251
+ userDetails.userID,
252
+ "server",
253
+ );
254
+
255
+ if (
256
+ !hasPermission(
257
+ permissions,
258
+ getParam(req, "serverID"),
259
+ POWER_LEVELS.INVITE,
260
+ )
261
+ ) {
262
+ log.warn("No permission!");
263
+ res.sendStatus(401);
264
+ return;
265
+ }
266
+
267
+ const duration = parseDuration(payload.duration, "ms");
268
+
269
+ if (!duration) {
270
+ res.sendStatus(400);
271
+ return;
272
+ }
273
+
274
+ const expires = new Date(Date.now() + duration);
275
+
276
+ const invite = await db.createInvite(
277
+ crypto.randomUUID(),
278
+ serverEntry.serverID,
279
+ userDetails.userID,
280
+ expires.toString(),
281
+ );
282
+ res.send(msgpack.encode(invite));
283
+ });
284
+
285
+ api.get("/server/:serverID/invites", protect, async (req, res) => {
286
+ const userDetails = getUser(req);
287
+
288
+ const permissions = await db.retrievePermissions(
289
+ userDetails.userID,
290
+ "server",
291
+ );
292
+
293
+ if (
294
+ !hasPermission(
295
+ permissions,
296
+ getParam(req, "serverID"),
297
+ POWER_LEVELS.INVITE,
298
+ )
299
+ ) {
300
+ res.sendStatus(401);
301
+ return;
302
+ }
303
+
304
+ const inviteList = await db.retrieveServerInvites(
305
+ getParam(req, "serverID"),
306
+ );
307
+ res.send(msgpack.encode(inviteList));
308
+ });
309
+
310
+ api.delete("/server/:id", protect, async (req, res) => {
311
+ const userDetails = getUser(req);
312
+ const serverID = getParam(req, "id");
313
+ const permissions = await db.retrievePermissions(
314
+ userDetails.userID,
315
+ "server",
316
+ );
317
+ if (hasPermission(permissions, serverID, POWER_LEVELS.DELETE)) {
318
+ await db.deleteServer(serverID);
319
+ res.sendStatus(200);
320
+ return;
321
+ }
322
+ res.sendStatus(401);
323
+ });
324
+
325
+ api.post("/server/:id/channels", protect, async (req, res) => {
326
+ const userDetails = getUser(req);
327
+ const serverID = getParam(req, "id");
328
+ // resourceID is serverID
329
+ const parsedBody = channelPayload.safeParse(req.body);
330
+ if (!parsedBody.success) {
331
+ res.status(400).json({
332
+ error: "Invalid channel payload",
333
+ issues: parsedBody.error.issues,
334
+ });
335
+ return;
336
+ }
337
+ const { name } = parsedBody.data;
338
+ const permissions = await db.retrievePermissions(
339
+ userDetails.userID,
340
+ "server",
341
+ );
342
+ if (hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
343
+ const channel = await db.createChannel(name, serverID);
344
+ res.send(msgpack.encode(channel));
345
+
346
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
347
+ // tell everyone about server change
348
+ for (const user of affectedUsers) {
349
+ notify(
350
+ user.userID,
351
+ "serverChange",
352
+ crypto.randomUUID(),
353
+ serverID,
354
+ );
355
+ }
356
+ return;
357
+ }
358
+ res.sendStatus(401);
359
+ });
360
+
361
+ api.get("/server/:id/channels", protect, async (req, res) => {
362
+ const serverID = getParam(req, "id");
363
+ const userDetails = getUser(req);
364
+ const permissions = await db.retrievePermissions(
365
+ userDetails.userID,
366
+ "server",
367
+ );
368
+ if (hasAnyPermission(permissions, serverID)) {
369
+ const channels = await db.retrieveChannels(serverID);
370
+ res.send(msgpack.encode(channels));
371
+ return;
372
+ }
373
+ res.sendStatus(401);
374
+ });
375
+
376
+ api.get("/server/:serverID/emoji", protect, async (req, res) => {
377
+ const rows = await db.retrieveEmojiList(getParam(req, "serverID"));
378
+ res.send(msgpack.encode(rows));
379
+ });
380
+
381
+ api.get("/server/:serverID/permissions", protect, async (req, res) => {
382
+ const userDetails = getUser(req);
383
+ const serverID = getParam(req, "serverID");
384
+ const permissions = await db.retrievePermissionsByResourceID(serverID);
385
+ const canSee = permissions.some(
386
+ (perm) => perm.userID === userDetails.userID,
387
+ );
388
+ if (!canSee) {
389
+ res.sendStatus(401);
390
+ return;
391
+ }
392
+ res.send(msgpack.encode(permissions));
393
+ });
394
+
395
+ api.delete("/channel/:id", protect, async (req, res) => {
396
+ const channelID = getParam(req, "id");
397
+ const userDetails = getUser(req);
398
+
399
+ const channel = await db.retrieveChannel(channelID);
400
+
401
+ if (!channel) {
402
+ res.sendStatus(401);
403
+ return;
404
+ }
405
+
406
+ const permissions = await db.retrievePermissions(
407
+ userDetails.userID,
408
+ "server",
409
+ );
410
+ for (const permission of permissions) {
411
+ if (
412
+ permission.resourceID === channel.serverID &&
413
+ permission.powerLevel > 50
414
+ ) {
415
+ await db.deleteChannel(channelID);
416
+
417
+ res.sendStatus(200);
418
+
419
+ const affectedUsers = await db.retrieveAffectedUsers(
420
+ channel.serverID,
421
+ );
422
+ // tell everyone about server change
423
+ for (const user of affectedUsers) {
424
+ notify(
425
+ user.userID,
426
+ "serverChange",
427
+ crypto.randomUUID(),
428
+ channel.serverID,
429
+ );
430
+ }
431
+ return;
432
+ }
433
+ }
434
+ res.sendStatus(401);
435
+ });
436
+
437
+ api.get("/channel/:id", protect, async (req, res) => {
438
+ const channel = await db.retrieveChannel(getParam(req, "id"));
439
+
440
+ if (channel) {
441
+ return res.send(msgpack.encode(channel));
442
+ } else {
443
+ return res.sendStatus(404);
444
+ }
445
+ });
446
+
447
+ api.delete("/permission/:permissionID", protect, async (req, res) => {
448
+ const permissionID = getParam(req, "permissionID");
449
+ const userDetails = getUser(req);
450
+ const permToDelete = await db.retrievePermission(permissionID);
451
+ if (!permToDelete) {
452
+ res.sendStatus(404);
453
+ return;
454
+ }
455
+
456
+ const permissions = await db.retrievePermissions(
457
+ userDetails.userID,
458
+ permToDelete.resourceType,
459
+ );
460
+
461
+ for (const perm of permissions) {
462
+ if (
463
+ perm.resourceID === permToDelete.resourceID &&
464
+ (perm.userID === userDetails.userID ||
465
+ (perm.powerLevel > POWER_LEVELS.DELETE &&
466
+ perm.powerLevel > permToDelete.powerLevel))
467
+ ) {
468
+ await db.deletePermission(permToDelete.permissionID);
469
+ res.sendStatus(200);
470
+ return;
471
+ }
472
+ }
473
+ res.sendStatus(401);
474
+ });
475
+
476
+ api.post("/userList/:channelID", protect, async (req, res) => {
477
+ const userDetails = getUser(req);
478
+ const channelID = getParam(req, "channelID");
479
+
480
+ const channel = await db.retrieveChannel(channelID);
481
+ if (!channel) {
482
+ res.sendStatus(404);
483
+ return;
484
+ }
485
+ const permissions = await db.retrievePermissions(
486
+ userDetails.userID,
487
+ "server",
488
+ );
489
+ for (const permission of permissions) {
490
+ if (permission.resourceID === channel.serverID) {
491
+ const groupMembers = await db.retrieveGroupMembers(channelID);
492
+ res.send(
493
+ msgpack.encode(
494
+ groupMembers.map((user) => censorUser(user)),
495
+ ),
496
+ );
497
+ return;
498
+ }
499
+ }
500
+ res.sendStatus(401);
501
+ });
502
+
503
+ api.post("/deviceList", protect, async (req, res) => {
504
+ const parsed = deviceListPayload.safeParse(req.body);
505
+ if (!parsed.success) {
506
+ res.status(400).json({
507
+ error: "Expected array of user ID strings",
508
+ issues: parsed.error.issues,
509
+ });
510
+ return;
511
+ }
512
+ const devices = await db.retrieveUserDeviceList(parsed.data);
513
+ res.send(msgpack.encode(devices));
514
+ });
515
+
516
+ api.get("/device/:id", protect, async (req, res) => {
517
+ const device = await db.retrieveDevice(getParam(req, "id"));
518
+
519
+ if (device) {
520
+ return res.send(msgpack.encode(device));
521
+ } else {
522
+ return res.sendStatus(404);
523
+ }
524
+ });
525
+
526
+ api.post("/device/:id/keyBundle", protect, async (req, res) => {
527
+ try {
528
+ const keyBundle = await db.getKeyBundle(getParam(req, "id"));
529
+ if (keyBundle) {
530
+ res.send(msgpack.encode(keyBundle));
531
+ } else {
532
+ res.sendStatus(404);
533
+ }
534
+ } catch {
535
+ res.sendStatus(500);
536
+ }
537
+ });
538
+
539
+ api.post("/device/:id/mail", protect, async (req, res) => {
540
+ const deviceDetails = req.device;
541
+ if (!deviceDetails) {
542
+ res.sendStatus(401);
543
+ return;
544
+ }
545
+ const inbox = await db.retrieveMail(deviceDetails.deviceID);
546
+ res.send(msgpack.encode(inbox));
547
+ });
548
+
549
+ api.post("/device/:id/connect", protect, async (req, res) => {
550
+ const parsedBody = connectPayload.safeParse(req.body);
551
+ if (!parsedBody.success) {
552
+ res.status(400).json({
553
+ error: "Invalid connect payload",
554
+ issues: parsedBody.error.issues,
555
+ });
556
+ return;
557
+ }
558
+ const { signed } = parsedBody.data;
559
+ const device = await db.retrieveDevice(getParam(req, "id"));
560
+ if (!device) {
561
+ res.sendStatus(404);
562
+ return;
563
+ }
564
+
565
+ const regKey = xSignOpen(signed, XUtils.decodeHex(device.signKey));
566
+ if (
567
+ regKey &&
568
+ tokenValidator(uuidStringify(regKey), TokenScopes.Connect)
569
+ ) {
570
+ const token = jwt.sign({ device }, getJwtSecret(), {
571
+ expiresIn: JWT_EXPIRY,
572
+ });
573
+ jwt.verify(token, getJwtSecret());
574
+
575
+ res.send(msgpack.encode({ deviceToken: token }));
576
+ } else {
577
+ res.sendStatus(401);
578
+ }
579
+ });
580
+
581
+ api.get("/device/:id/otk/count", protect, async (req, res) => {
582
+ const deviceDetails = req.device;
583
+ if (!deviceDetails) {
584
+ res.sendStatus(401);
585
+ return;
586
+ }
587
+ const count = await db.getOTKCount(deviceDetails.deviceID);
588
+ res.send(msgpack.encode({ count }));
589
+ });
590
+
591
+ api.post("/device/:id/otk", protect, async (req, res) => {
592
+ const parsedOTKs = z.array(PreKeysWSSchema).safeParse(req.body);
593
+ if (!parsedOTKs.success) {
594
+ res.status(400).json({
595
+ error: "Invalid OTK payload",
596
+ issues: parsedOTKs.error.issues,
597
+ });
598
+ return;
599
+ }
600
+ const submittedOTKs = parsedOTKs.data;
601
+ if (submittedOTKs.length === 0) {
602
+ res.sendStatus(200);
603
+ return;
604
+ }
605
+
606
+ const userDetails = getUser(req);
607
+
608
+ const deviceID = getParam(req, "id");
609
+ const otk = submittedOTKs[0];
610
+
611
+ const device = await db.retrieveDevice(deviceID);
612
+ if (!device || !otk) {
613
+ res.sendStatus(404);
614
+ return;
615
+ }
616
+
617
+ const message = xSignOpen(
618
+ otk.signature,
619
+ XUtils.decodeHex(device.signKey),
620
+ );
621
+
622
+ if (!message) {
623
+ res.sendStatus(401);
624
+ return;
625
+ }
626
+
627
+ await db.saveOTK(userDetails.userID, deviceID, submittedOTKs);
628
+ res.sendStatus(200);
629
+ });
630
+
631
+ api.get("/emoji/:emojiID/details", protect, async (req, res) => {
632
+ const emoji = await db.retrieveEmoji(getParam(req, "emojiID"));
633
+ res.send(msgpack.encode(emoji));
634
+ });
635
+
636
+ api.get("/emoji/:emojiID", protect, async (req, res) => {
637
+ const safeId = safePathParam.safeParse(getParam(req, "emojiID"));
638
+ if (!safeId.success) {
639
+ res.sendStatus(400);
640
+ return;
641
+ }
642
+ const filePath = "./emoji/" + safeId.data;
643
+ const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
644
+ if (!typeDetails) {
645
+ res.sendStatus(404);
646
+ return;
647
+ }
648
+ res.set("Content-type", typeDetails.mime);
649
+ res.set("Cache-control", "public, max-age=31536000");
650
+
651
+ const stream = fs.createReadStream(filePath);
652
+ stream.on("error", (err) => {
653
+ log.error(err.toString());
654
+ res.sendStatus(500);
655
+ });
656
+ stream.pipe(res);
657
+ });
658
+
659
+ api.post("/emoji/:serverID/json", protect, async (req, res) => {
660
+ const parsedPayload = emojiPayload.safeParse(req.body);
661
+ if (!parsedPayload.success) {
662
+ res.status(400).json({
663
+ error: "Invalid emoji payload",
664
+ issues: parsedPayload.error.issues,
665
+ });
666
+ return;
667
+ }
668
+ const payload = parsedPayload.data;
669
+
670
+ const userDetails = getUser(req);
671
+ const device = req.device;
672
+
673
+ if (!device) {
674
+ res.sendStatus(401);
675
+ return;
676
+ }
677
+
678
+ if (!payload.file) {
679
+ res.sendStatus(400);
680
+ return;
681
+ }
682
+
683
+ const buf = Buffer.from(XUtils.decodeBase64(payload.file));
684
+ const serverEntry = await db.retrieveServer(getParam(req, "serverID"));
685
+
686
+ const permissionList = await db.retrievePermissionsByResourceID(
687
+ getParam(req, "serverID"),
688
+ );
689
+
690
+ if (
691
+ !userHasPermission(
692
+ permissionList,
693
+ userDetails.userID,
694
+ POWER_LEVELS.EMOJI,
695
+ )
696
+ ) {
697
+ res.sendStatus(401);
698
+ return;
699
+ }
700
+ if (!serverEntry) {
701
+ res.sendStatus(404);
702
+ return;
703
+ }
704
+ if (!payload.name) {
705
+ res.sendStatus(400);
706
+ }
707
+ if (Buffer.byteLength(buf) > 256000) {
708
+ log.warn("File too big.");
709
+ res.sendStatus(413);
710
+ }
711
+
712
+ const mimeType = await fileTypeFromBuffer(buf);
713
+ if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
714
+ res.status(400).send({
715
+ error:
716
+ "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
717
+ String(mimeType?.ext),
718
+ });
719
+ return;
720
+ }
721
+
722
+ const emoji: Emoji = {
723
+ emojiID: crypto.randomUUID(),
724
+ name: payload.name,
725
+ owner: getParam(req, "serverID"),
726
+ };
727
+
728
+ await db.createEmoji(emoji);
729
+
730
+ try {
731
+ // write the file to disk
732
+ await fsp.writeFile("emoji/" + emoji.emojiID, buf);
733
+ log.info("Wrote new emoji " + emoji.emojiID);
734
+ res.send(msgpack.encode(emoji));
735
+ } catch (err: unknown) {
736
+ log.warn(String(err));
737
+ res.sendStatus(500);
738
+ }
739
+ });
740
+
741
+ api.post(
742
+ "/emoji/:serverID",
743
+ protect,
744
+ multer().single("emoji"),
745
+ async (req, res) => {
746
+ const parsedPayload = emojiPayload.safeParse(req.body);
747
+ if (!parsedPayload.success) {
748
+ res.status(400).json({
749
+ error: "Invalid emoji payload",
750
+ issues: parsedPayload.error.issues,
751
+ });
752
+ return;
753
+ }
754
+ const payload = parsedPayload.data;
755
+ const serverID = getParam(req, "serverID");
756
+ if (typeof serverID !== "string") {
757
+ res.sendStatus(400);
758
+ return;
759
+ }
760
+ const serverEntry = await db.retrieveServer(serverID);
761
+ const userDetails = getUser(req);
762
+ const deviceDetails = req.device;
763
+ if (!deviceDetails) {
764
+ res.sendStatus(401);
765
+ return;
766
+ }
767
+
768
+ const permissionList =
769
+ await db.retrievePermissionsByResourceID(serverID);
770
+
771
+ if (
772
+ !userHasPermission(
773
+ permissionList,
774
+ userDetails.userID,
775
+ POWER_LEVELS.EMOJI,
776
+ )
777
+ ) {
778
+ res.sendStatus(401);
779
+ return;
780
+ }
781
+
782
+ if (!serverEntry) {
783
+ res.sendStatus(404);
784
+ return;
785
+ }
786
+
787
+ if (!payload.name) {
788
+ res.sendStatus(400);
789
+ }
790
+
791
+ if (!req.file) {
792
+ log.warn("MISSING FILE");
793
+ res.sendStatus(400);
794
+ return;
795
+ }
796
+
797
+ if (Buffer.byteLength(req.file.buffer) > 256000) {
798
+ log.warn("File too big.");
799
+ res.sendStatus(413);
800
+ }
801
+
802
+ const mimeType = await fileTypeFromBuffer(req.file.buffer);
803
+ if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
804
+ res.status(400).send({
805
+ error:
806
+ "Unsupported file type. Expected jpeg, png, gif, apng, or avif but received " +
807
+ String(mimeType?.ext),
808
+ });
809
+ return;
810
+ }
811
+
812
+ const emoji: Emoji = {
813
+ emojiID: crypto.randomUUID(),
814
+ name: payload.name,
815
+ owner: serverID,
816
+ };
817
+
818
+ await db.createEmoji(emoji);
819
+
820
+ try {
821
+ // write the file to disk
822
+ await fsp.writeFile("emoji/" + emoji.emojiID, req.file.buffer);
823
+ log.info("Wrote new emoji " + emoji.emojiID);
824
+ res.send(msgpack.encode(emoji));
825
+ } catch (err: unknown) {
826
+ log.warn(String(err));
827
+ res.sendStatus(500);
828
+ }
829
+ },
830
+ );
831
+
832
+ // COMPLEX RESOURCES
833
+ api.use("/user", userRouter);
834
+
835
+ api.use("/file", fileRouter);
836
+
837
+ api.use("/avatar", avatarRouter);
838
+
839
+ api.use("/invite", inviteRouter);
840
+
841
+ setupDocs(api);
842
+
843
+ // Central error handler MUST be last. Handles both thrown AppErrors
844
+ // (client-safe status + message) and programmer errors (generic 500
845
+ // with full details logged server-side). See src/server/errors.ts
846
+ // for the CWE mapping.
847
+ api.use(errorHandler(log));
848
+ };
849
+
850
+ /**
851
+ * @ignore
852
+ */
853
+ const jestRun = () => {
854
+ return process.env["JEST_WORKER_ID"] !== undefined;
855
+ };