@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.
- package/README.md +82 -26
- package/dist/ClientManager.d.ts +24 -25
- package/dist/ClientManager.js +232 -509
- package/dist/ClientManager.js.map +1 -0
- package/dist/Database.d.ts +49 -41
- package/dist/Database.js +698 -716
- package/dist/Database.js.map +1 -0
- package/dist/Spire.d.ts +23 -15
- package/dist/Spire.js +518 -218
- package/dist/Spire.js.map +1 -0
- package/dist/__tests__/Database.spec.js +113 -73
- package/dist/__tests__/Database.spec.js.map +1 -0
- package/dist/db/schema.d.ts +134 -0
- package/dist/db/schema.js +2 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -5
- package/dist/index.js.map +1 -0
- package/dist/middleware/validate.d.ts +12 -0
- package/dist/middleware/validate.js +35 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/migrations/2026-04-06_initial-schema.d.ts +3 -0
- package/dist/migrations/2026-04-06_initial-schema.js +192 -0
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -0
- package/dist/run.js +26 -21
- package/dist/run.js.map +1 -0
- package/dist/server/avatar.d.ts +3 -4
- package/dist/server/avatar.js +85 -67
- package/dist/server/avatar.js.map +1 -0
- package/dist/server/errors.d.ts +59 -0
- package/dist/server/errors.js +94 -0
- package/dist/server/errors.js.map +1 -0
- package/dist/server/file.d.ts +3 -4
- package/dist/server/file.js +101 -61
- package/dist/server/file.js.map +1 -0
- package/dist/server/index.d.ts +9 -6
- package/dist/server/index.js +595 -70
- package/dist/server/index.js.map +1 -0
- package/dist/server/invite.d.ts +4 -5
- package/dist/server/invite.js +21 -103
- package/dist/server/invite.js.map +1 -0
- package/dist/server/openapi.d.ts +2 -0
- package/dist/server/openapi.js +40 -0
- package/dist/server/openapi.js.map +1 -0
- package/dist/server/permissions.d.ts +16 -0
- package/dist/server/permissions.js +22 -0
- package/dist/server/permissions.js.map +1 -0
- package/dist/server/rateLimit.d.ts +28 -0
- package/dist/server/rateLimit.js +58 -0
- package/dist/server/rateLimit.js.map +1 -0
- package/dist/server/user.d.ts +4 -7
- package/dist/server/user.js +66 -76
- package/dist/server/user.js.map +1 -0
- package/dist/server/utils.d.ts +35 -7
- package/dist/server/utils.js +50 -6
- package/dist/server/utils.js.map +1 -0
- package/dist/types/express.d.ts +20 -0
- package/dist/types/express.js +2 -0
- package/dist/types/express.js.map +1 -0
- package/dist/utils/createLogger.js +13 -19
- package/dist/utils/createLogger.js.map +1 -0
- package/dist/utils/createUint8UUID.js +6 -10
- package/dist/utils/createUint8UUID.js.map +1 -0
- package/dist/utils/jwtSecret.d.ts +7 -0
- package/dist/utils/jwtSecret.js +15 -0
- package/dist/utils/jwtSecret.js.map +1 -0
- package/dist/utils/loadEnv.js +7 -22
- package/dist/utils/loadEnv.js.map +1 -0
- package/dist/utils/msgpack.d.ts +2 -0
- package/dist/utils/msgpack.js +4 -0
- package/dist/utils/msgpack.js.map +1 -0
- package/package.json +91 -63
- package/src/ClientManager.ts +434 -0
- package/src/Database.ts +925 -0
- package/src/Spire.ts +878 -0
- package/src/__tests__/Database.spec.ts +167 -0
- package/src/ambient-modules.d.ts +1 -0
- package/src/db/schema.ts +165 -0
- package/src/index.ts +3 -0
- package/src/middleware/validate.ts +38 -0
- package/src/migrations/2026-04-06_initial-schema.ts +218 -0
- package/src/run.ts +37 -0
- package/src/server/avatar.ts +141 -0
- package/src/server/errors.ts +133 -0
- package/src/server/file.ts +172 -0
- package/src/server/index.ts +855 -0
- package/src/server/invite.ts +65 -0
- package/src/server/openapi.ts +51 -0
- package/src/server/permissions.ts +40 -0
- package/src/server/rateLimit.ts +86 -0
- package/src/server/user.ts +125 -0
- package/src/server/utils.ts +59 -0
- package/src/types/express.ts +23 -0
- package/src/utils/createLogger.ts +47 -0
- package/src/utils/createUint8UUID.ts +9 -0
- package/src/utils/jwtSecret.ts +16 -0
- package/src/utils/loadEnv.ts +15 -0
- package/src/utils/msgpack.ts +4 -0
- package/avatars/169d76cb-6e7c-4e24-8224-017673eed8ff +0 -0
- package/avatars/1cae8d0b-0c6b-4c73-b25c-2d2349a57122 +0 -0
- package/avatars/1d87bc2b-71fc-4818-8004-40d04093e5fc +0 -0
- package/avatars/1f2c3d62-8b4d-465a-9895-51a24caef00d +0 -0
- package/avatars/245ee7fc-1004-41ab-adab-a04c9ceb9d7a +0 -0
- package/avatars/2465c28c-bdaf-4fa2-b42a-d054f04dc39b +0 -0
- package/avatars/3900a674-a2dd-4996-a61d-8c29b3270f41 +0 -0
- package/avatars/3c3b9c77-ea50-45e7-bb25-65d6f3d2a219 +0 -0
- package/avatars/414a2ff4-ad2f-4a7d-aa27-3b09ad3522b2 +0 -0
- package/avatars/522fe504-f0ad-4ed4-9dc6-e5dc2338e531 +0 -0
- package/avatars/53e5eb29-e7d1-4d58-add9-d44f39f0cfb7 +0 -0
- package/avatars/54d3f757-1038-41c8-bfb9-efd37b6e8ebe +0 -0
- package/avatars/623e86d7-c49c-46f6-9b76-ca70c9dbc43b +0 -0
- package/avatars/66e2abae-60f5-4dd1-b9fb-297a4bedfeb0 +0 -0
- package/avatars/6f37980e-f6fa-4d6d-9206-24050b403f45 +0 -0
- package/avatars/80138ece-eb5c-4b20-817b-903d6b0f54ae +0 -0
- package/avatars/841c77d3-37c4-431b-be22-672888062874 +0 -0
- package/avatars/88051a61-2bda-4750-95a3-5fb0b4918149 +0 -0
- package/avatars/89540973-c421-4bf1-8b89-9f1eaa51c1b5 +0 -0
- package/avatars/8a802fad-8c99-4942-8f80-47cec600149c +0 -0
- package/avatars/90531d8a-907a-4a1a-ac45-c85e4acb0df9 +0 -0
- package/avatars/9b7d0da9-b8d6-4801-b128-9993f79f464a +0 -0
- package/avatars/9bc456f1-c4c4-48a1-b9e6-fd44dd744a72 +0 -0
- package/avatars/9cf878bf-7430-49ec-a47a-78f3c93793dd +0 -0
- package/avatars/9ee82847-6ad3-45e5-92b9-f474a6c54d96 +0 -0
- package/avatars/ab44c857-d81d-4c88-85db-32f9532e5376 +0 -0
- package/avatars/b396a8d2-dc14-48d2-aac4-3755dc637051 +0 -0
- package/avatars/b6ac11c5-a8b2-4e0a-995a-9c87f1e58787 +0 -0
- package/avatars/b79d6855-b738-434c-be32-809637e62b9b +0 -0
- package/avatars/bbcd0188-d6a5-48ae-90fb-be5ff30599ab +0 -0
- package/avatars/bc7a9e0e-4720-4a6e-a90d-c11fec94d380 +0 -0
- package/avatars/c1c4889f-8383-4041-8bdd-9fded4046f37 +0 -0
- package/avatars/c4c7203c-d93a-4749-ade2-17053acf1d2a +0 -0
- package/avatars/ca974a70-0a23-4668-8b80-c4304dc7f793 +0 -0
- package/avatars/cf119a0d-eb3f-4bed-905b-f14a876c3535 +0 -0
- package/avatars/d464b03d-30c2-49e3-a666-80aefa8a1b35 +0 -0
- package/avatars/da0eee89-82f0-4d45-ab48-7d2786b634c5 +0 -0
- package/avatars/de4c77a5-68e9-4bb2-b40d-d964bf377d61 +0 -0
- package/avatars/dea95395-7d0b-42aa-a9ed-40c7d4fb4c48 +0 -0
- package/avatars/edb30749-59ba-4aa2-9c52-0fb22048f4cf +0 -0
- package/avatars/f17c245a-af7d-445b-9365-49f7f54b1eeb +0 -0
- package/avatars/f1ee6a35-b262-4dbf-99f5-3d011e3b98ec +0 -0
- package/avatars/f802bdd0-345d-41f6-9184-0f30e1258fb3 +0 -0
- package/dist/migrations/20210103192527_users.d.ts +0 -3
- package/dist/migrations/20210103192527_users.js +0 -30
- package/dist/migrations/20210103193502_mail.d.ts +0 -3
- package/dist/migrations/20210103193502_mail.js +0 -35
- package/dist/migrations/20210103193525_preKeys.d.ts +0 -3
- package/dist/migrations/20210103193525_preKeys.js +0 -30
- package/dist/migrations/20210103193553_oneTimeKeys.d.ts +0 -3
- package/dist/migrations/20210103193553_oneTimeKeys.js +0 -30
- package/dist/migrations/20210103193615_servers.d.ts +0 -3
- package/dist/migrations/20210103193615_servers.js +0 -28
- package/dist/migrations/20210103193729_channels.d.ts +0 -3
- package/dist/migrations/20210103193729_channels.js +0 -28
- package/dist/migrations/20210103193749_permissions.d.ts +0 -3
- package/dist/migrations/20210103193749_permissions.js +0 -30
- package/dist/migrations/20210103193801_files.d.ts +0 -3
- package/dist/migrations/20210103193801_files.js +0 -28
- package/files/00ea7368-45a7-4f6a-a199-974da14be1a0 +0 -0
- package/files/039503d2-a170-4962-b921-c97994ba64ff +0 -0
- package/files/14b6fa02-4cbb-40df-be4f-a07187cb619e +0 -0
- package/files/15c04cb1-dc6a-4f19-aa6f-4a3b92d05bf7 +0 -0
- package/files/2a8d411c-8b92-4532-b84d-d64c638d6293 +0 -0
- package/files/37de2cd3-08a8-4044-9c37-e13386765f3d +0 -0
- package/files/42452029-284e-4c81-9f18-feb6ce309eed +0 -0
- package/files/43d09f2f-29c8-415f-8c4a-23f2a32eb79e +0 -0
- package/files/52992923-33ab-44a1-8118-605e9b4856a7 +0 -0
- package/files/53180681-36e2-49c0-8382-94dca0da09bf +0 -0
- package/files/5a56cd7b-1d04-4619-b60f-1e5515b9164a +0 -0
- package/files/5ced7676-20c4-4219-a4f2-70a25eb7eea8 +0 -0
- package/files/60e787ff-8ec5-444d-b963-0aaf5313b53e +0 -0
- package/files/67a17729-fcb7-4339-9499-1fc08fea72ca +0 -0
- package/files/68d09565-908d-4a67-8f09-e183f8708eb4 +0 -0
- package/files/70e587c5-56e8-47f0-bd36-691efcb0cc2e +0 -4
- package/files/7a227619-b715-4e2c-a79d-b2158d56a799 +0 -0
- package/files/7e3cc3ea-b706-4835-994d-65d33acaf369 +0 -0
- package/files/816f72a0-65dc-40c1-8862-1d22065cca3c +0 -0
- package/files/8bf84972-5086-4631-a752-093d7b1a098b +0 -0
- package/files/8c46e3bc-3f2e-441d-b8cc-17428fc3d219 +0 -0
- package/files/8eb83364-8826-4eee-895a-ba5cd3ab85e9 +0 -0
- package/files/8faefaea-14e3-49e4-ac74-9710622bfae9 +0 -0
- package/files/91af08c2-9dca-41f4-b6c6-b7bf33505df9 +0 -0
- package/files/9349dffa-35dd-49a0-a651-10b438038f95 +0 -0
- package/files/b0a12a41-6283-4f27-b2c8-66774991d96c +0 -0
- package/files/b28afb08-d18b-48a8-93c3-6a59c66d8fee +0 -0
- package/files/be60fe01-1578-4363-a908-41500092b77d +0 -0
- package/files/cf7b90e3-734a-4453-9ff3-9a331404b5ef +0 -0
- package/files/d1be30aa-8ff4-41c1-b360-f65922029047 +0 -0
- package/files/d2d31a57-a413-443c-9b97-fa9ca394ef0b +0 -0
- package/files/d357f223-b786-478d-a113-b53cb62acdab +0 -0
- package/files/d4a69ee0-05c4-4ff0-8753-624cdd0f24e9 +0 -0
- package/files/d57f411b-1874-4f00-a6b0-2f86de6b229d +0 -0
- package/files/d85aaa33-7f70-4a70-b3d2-cad667beb38c +0 -0
- package/files/db2fc236-dbe0-4d24-bfaf-884829fd090a +0 -0
- package/files/df2e20ec-6dcc-4402-94ee-7d1163f84197 +0 -0
- package/files/e0880ab5-6d49-4dc0-a5ec-0d3793a8a7b8 +0 -0
- package/files/e59ee9c6-5aea-48ea-b3d9-94a324dc2260 +0 -0
- package/files/e6d7aad6-4220-4ce7-85f8-89d0b1f0cc89 +0 -0
- package/files/f0bfc98c-3534-459d-931a-7c6e77bde153 +0 -0
- package/files/f8443740-050c-4714-9bf6-334783ae6ffd +0 -0
- package/files/fc405ae7-f9fb-455d-ac8c-0ca0d88d4115 +0 -0
- package/jest.config.js +0 -13
- package/spire.sqlite +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Database } from "../Database.ts";
|
|
2
|
+
import type winston from "winston";
|
|
3
|
+
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as fsp from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
import express from "express";
|
|
8
|
+
|
|
9
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
10
|
+
import { FilePayloadSchema } from "@vex-chat/types";
|
|
11
|
+
|
|
12
|
+
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
13
|
+
import multer from "multer";
|
|
14
|
+
import { z } from "zod/v4";
|
|
15
|
+
|
|
16
|
+
import { uploadLimiter } from "./rateLimit.ts";
|
|
17
|
+
import { getParam, getUser } from "./utils.ts";
|
|
18
|
+
|
|
19
|
+
import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
20
|
+
|
|
21
|
+
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
22
|
+
|
|
23
|
+
export const getAvatarRouter = (db: Database, log: winston.Logger) => {
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
router.get("/:userID", async (req, res) => {
|
|
27
|
+
const safeId = safePathParam.safeParse(getParam(req, "userID"));
|
|
28
|
+
if (!safeId.success) {
|
|
29
|
+
res.sendStatus(400);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const filePath = "./avatars/" + safeId.data;
|
|
33
|
+
const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
|
|
34
|
+
if (!typeDetails) {
|
|
35
|
+
res.sendStatus(404);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.set("Content-type", typeDetails.mime);
|
|
39
|
+
res.set("Cache-control", "public, max-age=31536000");
|
|
40
|
+
|
|
41
|
+
const stream = fs.createReadStream(filePath);
|
|
42
|
+
stream.on("error", (err) => {
|
|
43
|
+
log.error(err.toString());
|
|
44
|
+
res.sendStatus(500);
|
|
45
|
+
});
|
|
46
|
+
stream.pipe(res);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
router.post("/:userID/json", protect, async (req, res) => {
|
|
50
|
+
const parsed = FilePayloadSchema.safeParse(req.body);
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
res.status(400).json({
|
|
53
|
+
error: "Invalid file payload",
|
|
54
|
+
issues: parsed.error.issues,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const payload = parsed.data;
|
|
59
|
+
const userDetails = getUser(req);
|
|
60
|
+
const deviceDetails = req.device;
|
|
61
|
+
|
|
62
|
+
if (!deviceDetails) {
|
|
63
|
+
res.sendStatus(401);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!payload.file) {
|
|
68
|
+
log.warn("MISSING FILE");
|
|
69
|
+
res.sendStatus(400);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buf = Buffer.from(XUtils.decodeBase64(payload.file));
|
|
74
|
+
const mimeType = await fileTypeFromBuffer(buf);
|
|
75
|
+
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
76
|
+
res.status(400).send({
|
|
77
|
+
error:
|
|
78
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or svg but received " +
|
|
79
|
+
String(mimeType?.ext),
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// write the file to disk
|
|
86
|
+
await fsp.writeFile("avatars/" + userDetails.userID, buf);
|
|
87
|
+
log.info("Wrote new avatar " + userDetails.userID);
|
|
88
|
+
res.sendStatus(200);
|
|
89
|
+
} catch (err: unknown) {
|
|
90
|
+
log.warn(String(err));
|
|
91
|
+
res.sendStatus(500);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
router.post(
|
|
96
|
+
"/:userID",
|
|
97
|
+
uploadLimiter,
|
|
98
|
+
protect,
|
|
99
|
+
multer().single("avatar"),
|
|
100
|
+
async (req, res) => {
|
|
101
|
+
const userDetails = getUser(req);
|
|
102
|
+
const deviceDetails = req.device;
|
|
103
|
+
|
|
104
|
+
if (!deviceDetails) {
|
|
105
|
+
res.sendStatus(401);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!req.file) {
|
|
110
|
+
log.warn("MISSING FILE");
|
|
111
|
+
res.sendStatus(400);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const mimeType = await fileTypeFromBuffer(req.file.buffer);
|
|
116
|
+
if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
|
|
117
|
+
res.status(400).send({
|
|
118
|
+
error:
|
|
119
|
+
"Unsupported file type. Expected jpeg, png, gif, apng, avif, or svg but received " +
|
|
120
|
+
String(mimeType?.ext),
|
|
121
|
+
});
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// write the file to disk
|
|
127
|
+
await fsp.writeFile(
|
|
128
|
+
"avatars/" + userDetails.userID,
|
|
129
|
+
req.file.buffer,
|
|
130
|
+
);
|
|
131
|
+
log.info("Wrote new avatar " + userDetails.userID);
|
|
132
|
+
res.sendStatus(200);
|
|
133
|
+
} catch (err: unknown) {
|
|
134
|
+
log.warn(String(err));
|
|
135
|
+
res.sendStatus(500);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return router;
|
|
141
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized HTTP error handling.
|
|
3
|
+
*
|
|
4
|
+
* Fixes two classes of CodeQL findings:
|
|
5
|
+
*
|
|
6
|
+
* - **CWE-209 / CWE-497 — Information exposure through stack trace.**
|
|
7
|
+
* Previously, every route catch block did
|
|
8
|
+
* `res.status(500).send(String(err))`, leaking raw error objects
|
|
9
|
+
* (which include database internals, file paths, and stack traces)
|
|
10
|
+
* back to the client. The new pattern: throw `AppError(status, msg)`
|
|
11
|
+
* or let an unknown error propagate, and the single 4-arg middleware
|
|
12
|
+
* below converts it into a JSON response with a generic message
|
|
13
|
+
* while logging the full internals server-side via winston.
|
|
14
|
+
*
|
|
15
|
+
* - **CWE-79 / CWE-116 — Exception text reinterpreted as HTML.** Express's
|
|
16
|
+
* default finalhandler sends thrown `Error` objects as
|
|
17
|
+
* `Content-Type: text/html`, which means `throw new Error("bad param: "
|
|
18
|
+
* + req.params.name)` became reflected XSS when the error page
|
|
19
|
+
* rendered. The handler below ALWAYS sends `application/json`, so
|
|
20
|
+
* even if a message somehow contains user input, the browser can't
|
|
21
|
+
* execute it. The companion fix is `getParam()` in `utils.ts` now
|
|
22
|
+
* throwing `AppError(400, "Missing route parameter")` with no user
|
|
23
|
+
* input in the message string.
|
|
24
|
+
*
|
|
25
|
+
* Express 5 has native async support, so throwing from an async
|
|
26
|
+
* handler auto-forwards to `next(err)` and hits this middleware. No
|
|
27
|
+
* `express-async-errors` shim needed.
|
|
28
|
+
*/
|
|
29
|
+
import type { ErrorRequestHandler } from "express";
|
|
30
|
+
import type winston from "winston";
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
import { ZodError } from "zod/v4";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Operational HTTP errors that are safe to surface to the client.
|
|
38
|
+
*
|
|
39
|
+
* - `status` — HTTP status code (400, 401, 403, 404, 409, etc.)
|
|
40
|
+
* - `message` — client-safe message, MUST NOT contain request data
|
|
41
|
+
* (route params, body fields, query strings). Anything operator-
|
|
42
|
+
* only (database errors, file paths) belongs in winston logs, not
|
|
43
|
+
* in this string.
|
|
44
|
+
*
|
|
45
|
+
* Anything that isn't an `AppError` (raw `Error`, `TypeError`, a
|
|
46
|
+
* rejected promise from a DB query, etc.) is treated by the central
|
|
47
|
+
* handler as a **programmer error** — the client gets a generic 500
|
|
48
|
+
* with no detail, and the real error is logged server-side.
|
|
49
|
+
*/
|
|
50
|
+
export class AppError extends Error {
|
|
51
|
+
public readonly status: number;
|
|
52
|
+
|
|
53
|
+
constructor(status: number, message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "AppError";
|
|
56
|
+
this.status = status;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Factory producing the central Express 5 error middleware.
|
|
62
|
+
*
|
|
63
|
+
* Register this as the LAST middleware in the app, after every
|
|
64
|
+
* route and router has been mounted:
|
|
65
|
+
*
|
|
66
|
+
* api.use("/user", userRouter);
|
|
67
|
+
* // ... all other routers ...
|
|
68
|
+
* api.use(errorHandler(log));
|
|
69
|
+
*/
|
|
70
|
+
export const errorHandler =
|
|
71
|
+
(log: winston.Logger): ErrorRequestHandler =>
|
|
72
|
+
(err, req, res, _next) => {
|
|
73
|
+
// If headers already went out there's nothing safe to do except
|
|
74
|
+
// let Express's default handler close the socket.
|
|
75
|
+
if (res.headersSent) {
|
|
76
|
+
_next(err);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const requestId = randomUUID();
|
|
81
|
+
|
|
82
|
+
let status = 500;
|
|
83
|
+
let clientMessage = "Internal Server Error";
|
|
84
|
+
let details: unknown;
|
|
85
|
+
|
|
86
|
+
if (err instanceof ZodError) {
|
|
87
|
+
// Validation failure at a trust boundary. The issue list is
|
|
88
|
+
// structured JSON (no raw user input as a rendered string),
|
|
89
|
+
// so it's safe to surface to help clients fix their payload.
|
|
90
|
+
status = 400;
|
|
91
|
+
clientMessage = "Validation failed";
|
|
92
|
+
details = err.issues;
|
|
93
|
+
} else if (err instanceof AppError) {
|
|
94
|
+
status = err.status;
|
|
95
|
+
clientMessage = err.message;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Log the full internals server-side. `err instanceof Error` also
|
|
99
|
+
// catches AppError (extends Error), so we get stacks and messages
|
|
100
|
+
// for every branch in the logs.
|
|
101
|
+
if (err instanceof Error) {
|
|
102
|
+
log.error("request failed", {
|
|
103
|
+
error: {
|
|
104
|
+
message: err.message,
|
|
105
|
+
name: err.name,
|
|
106
|
+
stack: err.stack,
|
|
107
|
+
},
|
|
108
|
+
method: req.method,
|
|
109
|
+
path: req.path,
|
|
110
|
+
requestId,
|
|
111
|
+
status,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
log.error("request failed", {
|
|
115
|
+
error: String(err),
|
|
116
|
+
method: req.method,
|
|
117
|
+
path: req.path,
|
|
118
|
+
requestId,
|
|
119
|
+
status,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ALWAYS JSON — prevents the exception-text-as-HTML XSS vector.
|
|
124
|
+
res.status(status)
|
|
125
|
+
.type("application/json")
|
|
126
|
+
.json({
|
|
127
|
+
error: {
|
|
128
|
+
message: clientMessage,
|
|
129
|
+
requestId,
|
|
130
|
+
...(details !== undefined ? { details } : {}),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { Database } from "../Database.ts";
|
|
2
|
+
import type { FileSQL } 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
|
+
import * as path from "node:path";
|
|
8
|
+
|
|
9
|
+
import express from "express";
|
|
10
|
+
|
|
11
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
12
|
+
import { FilePayloadSchema } from "@vex-chat/types";
|
|
13
|
+
|
|
14
|
+
import multer from "multer";
|
|
15
|
+
import { z } from "zod/v4";
|
|
16
|
+
|
|
17
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
18
|
+
|
|
19
|
+
import { uploadLimiter } from "./rateLimit.ts";
|
|
20
|
+
import { getParam } from "./utils.ts";
|
|
21
|
+
|
|
22
|
+
import { protect } from "./index.ts";
|
|
23
|
+
|
|
24
|
+
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
25
|
+
|
|
26
|
+
export const getFileRouter = (db: Database, log: winston.Logger) => {
|
|
27
|
+
const router = express.Router();
|
|
28
|
+
|
|
29
|
+
router.get("/:id", protect, async (req, res) => {
|
|
30
|
+
const safeId = safePathParam.safeParse(getParam(req, "id"));
|
|
31
|
+
if (!safeId.success) {
|
|
32
|
+
res.sendStatus(400);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const entry = await db.retrieveFile(safeId.data);
|
|
36
|
+
if (!entry) {
|
|
37
|
+
res.sendStatus(404);
|
|
38
|
+
} else {
|
|
39
|
+
const stream = fs.createReadStream("./files/" + entry.fileID);
|
|
40
|
+
stream.on("error", (err) => {
|
|
41
|
+
log.error(err.toString());
|
|
42
|
+
res.sendStatus(500);
|
|
43
|
+
});
|
|
44
|
+
stream.pipe(res);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
router.get("/:id/details", protect, async (req, res) => {
|
|
49
|
+
const safeId = safePathParam.safeParse(getParam(req, "id"));
|
|
50
|
+
if (!safeId.success) {
|
|
51
|
+
res.sendStatus(400);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const entry = await db.retrieveFile(safeId.data);
|
|
55
|
+
if (!entry) {
|
|
56
|
+
res.sendStatus(404);
|
|
57
|
+
} else {
|
|
58
|
+
fs.stat(path.resolve("./files/" + entry.fileID), (err, stat) => {
|
|
59
|
+
if (err) {
|
|
60
|
+
res.sendStatus(500);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.set("Cache-control", "public, max-age=31536000");
|
|
64
|
+
res.send(
|
|
65
|
+
msgpack.encode({
|
|
66
|
+
...entry,
|
|
67
|
+
birthtime: stat.birthtime,
|
|
68
|
+
size: stat.size,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.post("/json", protect, async (req, res) => {
|
|
76
|
+
const deviceDetails = req.device;
|
|
77
|
+
|
|
78
|
+
if (!deviceDetails) {
|
|
79
|
+
res.sendStatus(401);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parsed = FilePayloadSchema.safeParse(req.body);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
res.status(400).json({
|
|
86
|
+
error: "Invalid file payload",
|
|
87
|
+
issues: parsed.error.issues,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const payload = parsed.data;
|
|
92
|
+
|
|
93
|
+
if (payload.nonce === "") {
|
|
94
|
+
res.sendStatus(400);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!payload.file) {
|
|
99
|
+
res.sendStatus(400);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const buf = Buffer.from(XUtils.decodeBase64(payload.file));
|
|
104
|
+
|
|
105
|
+
const newFile: FileSQL = {
|
|
106
|
+
fileID: crypto.randomUUID(),
|
|
107
|
+
nonce: payload.nonce,
|
|
108
|
+
owner: payload.owner,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await fsp.writeFile("files/" + newFile.fileID, buf);
|
|
112
|
+
log.info("Wrote new file " + newFile.fileID);
|
|
113
|
+
|
|
114
|
+
await db.createFile(newFile);
|
|
115
|
+
res.send(msgpack.encode(newFile));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Multipart file upload — form fields are strings from multer, not full FilePayload
|
|
119
|
+
const multipartFields = z.object({
|
|
120
|
+
nonce: z.string().min(1),
|
|
121
|
+
owner: z.string().min(1),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
router.post(
|
|
125
|
+
"/",
|
|
126
|
+
uploadLimiter,
|
|
127
|
+
protect,
|
|
128
|
+
multer().single("file"),
|
|
129
|
+
async (req, res) => {
|
|
130
|
+
const deviceDetails = req.device;
|
|
131
|
+
|
|
132
|
+
if (!deviceDetails) {
|
|
133
|
+
res.sendStatus(400);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parsed = multipartFields.safeParse(req.body);
|
|
138
|
+
if (!parsed.success) {
|
|
139
|
+
res.status(400).json({
|
|
140
|
+
error: "Invalid file payload",
|
|
141
|
+
issues: parsed.error.issues,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const payload = parsed.data;
|
|
146
|
+
|
|
147
|
+
if (req.file === undefined) {
|
|
148
|
+
res.sendStatus(400);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (payload.nonce === "") {
|
|
153
|
+
res.sendStatus(400);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const newFile: FileSQL = {
|
|
158
|
+
fileID: crypto.randomUUID(),
|
|
159
|
+
nonce: payload.nonce,
|
|
160
|
+
owner: payload.owner,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await fsp.writeFile("files/" + newFile.fileID, req.file.buffer);
|
|
164
|
+
log.info("Wrote new file " + newFile.fileID);
|
|
165
|
+
|
|
166
|
+
await db.createFile(newFile);
|
|
167
|
+
res.send(msgpack.encode(newFile));
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return router;
|
|
172
|
+
};
|