@vex-chat/spire 4.0.0 → 4.1.1
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/dist/Database.d.ts +8 -0
- package/dist/Database.js +68 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.js +1 -1
- package/dist/Spire.js.map +1 -1
- package/dist/server/avatar.js +3 -2
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +580 -91
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/index.js +163 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +326 -3
- package/dist/server/passkey.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/package.json +2 -2
- package/src/Database.ts +83 -0
- package/src/Spire.ts +1 -1
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +17 -1
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/server/avatar.ts +3 -2
- package/src/server/cliPasskeyPage.ts +580 -91
- package/src/server/index.ts +211 -31
- package/src/server/passkey.ts +480 -3
- package/src/server/serverIcon.ts +199 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
8
|
+
import type { Server } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as fsp from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
import express from "express";
|
|
14
|
+
|
|
15
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
16
|
+
|
|
17
|
+
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
18
|
+
import multer from "multer";
|
|
19
|
+
import { z } from "zod/v4";
|
|
20
|
+
|
|
21
|
+
import { POWER_LEVELS } from "../ClientManager.ts";
|
|
22
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
23
|
+
|
|
24
|
+
import { hasPermission } from "./permissions.ts";
|
|
25
|
+
import { uploadLimiter } from "./rateLimit.ts";
|
|
26
|
+
import { getParam, getUser } from "./utils.ts";
|
|
27
|
+
|
|
28
|
+
import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
29
|
+
|
|
30
|
+
const SERVER_ICON_DIR = "server-icons";
|
|
31
|
+
const MAX_SERVER_ICON_BYTES = 5 * 1024 * 1024;
|
|
32
|
+
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
33
|
+
const serverIconUpload = multer({
|
|
34
|
+
limits: { fields: 1, files: 1, fileSize: MAX_SERVER_ICON_BYTES, parts: 2 },
|
|
35
|
+
});
|
|
36
|
+
const serverIconJsonPayload = z.object({
|
|
37
|
+
file: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(Math.ceil((MAX_SERVER_ICON_BYTES * 4) / 3) + 4),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type NotifyServerChange = (serverID: string) => Promise<void>;
|
|
44
|
+
|
|
45
|
+
export async function deleteServerIconFile(iconID: string): Promise<void> {
|
|
46
|
+
const safeID = safePathParam.safeParse(iconID);
|
|
47
|
+
if (!safeID.success) return;
|
|
48
|
+
await fsp
|
|
49
|
+
.unlink(`${SERVER_ICON_DIR}/${safeID.data}`)
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const getServerIconRouter = (
|
|
54
|
+
db: Database,
|
|
55
|
+
notifyServerChange: NotifyServerChange,
|
|
56
|
+
) => {
|
|
57
|
+
const router = express.Router();
|
|
58
|
+
|
|
59
|
+
router.get("/:iconID", async (req, res) => {
|
|
60
|
+
const safeID = safePathParam.safeParse(getParam(req, "iconID"));
|
|
61
|
+
if (!safeID.success) {
|
|
62
|
+
res.sendStatus(400);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filePath = `${SERVER_ICON_DIR}/${safeID.data}`;
|
|
67
|
+
const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
|
|
68
|
+
if (!typeDetails) {
|
|
69
|
+
res.sendStatus(404);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
res.set("Content-Type", typeDetails.mime);
|
|
74
|
+
res.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
75
|
+
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
76
|
+
const stream = fs.createReadStream(filePath);
|
|
77
|
+
stream.on("error", () => {
|
|
78
|
+
if (!res.headersSent) res.sendStatus(500);
|
|
79
|
+
else res.destroy();
|
|
80
|
+
});
|
|
81
|
+
stream.pipe(res);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.post("/:serverID/json", uploadLimiter, protect, async (req, res) => {
|
|
85
|
+
const parsed = serverIconJsonPayload.safeParse(req.body);
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
res.status(400).json({
|
|
88
|
+
error: "Invalid server icon payload",
|
|
89
|
+
issues: parsed.error.issues,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let buffer: Buffer;
|
|
95
|
+
try {
|
|
96
|
+
buffer = Buffer.from(XUtils.decodeBase64(parsed.data.file));
|
|
97
|
+
} catch {
|
|
98
|
+
res.status(400).json({ error: "Icon must be valid base64." });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await saveIcon(req, res, db, notifyServerChange, buffer);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
router.post(
|
|
105
|
+
"/:serverID",
|
|
106
|
+
uploadLimiter,
|
|
107
|
+
protect,
|
|
108
|
+
serverIconUpload.single("icon"),
|
|
109
|
+
async (req, res) => {
|
|
110
|
+
if (!req.file) {
|
|
111
|
+
res.sendStatus(400);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await saveIcon(req, res, db, notifyServerChange, req.file.buffer);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
router.delete("/:serverID", protect, async (req, res) => {
|
|
119
|
+
const serverID = getParam(req, "serverID");
|
|
120
|
+
if (!(await canManageServer(db, getUser(req).userID, serverID))) {
|
|
121
|
+
res.sendStatus(403);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const existing = await db.retrieveServer(serverID);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
res.sendStatus(404);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const updated = await db.updateServer(serverID, { icon: null });
|
|
131
|
+
if (!updated) {
|
|
132
|
+
res.sendStatus(404);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (existing.icon) await deleteServerIconFile(existing.icon);
|
|
137
|
+
await notifyServerChange(serverID);
|
|
138
|
+
res.send(msgpack.encode(updated));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return router;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
async function canManageServer(
|
|
145
|
+
db: Database,
|
|
146
|
+
userID: string,
|
|
147
|
+
serverID: string,
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
const permissions = await db.retrievePermissions(userID, "server");
|
|
150
|
+
return hasPermission(permissions, serverID, POWER_LEVELS.CREATE);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function saveIcon(
|
|
154
|
+
req: express.Request,
|
|
155
|
+
res: express.Response,
|
|
156
|
+
db: Database,
|
|
157
|
+
notifyServerChange: NotifyServerChange,
|
|
158
|
+
buffer: Buffer,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const serverID = getParam(req, "serverID");
|
|
161
|
+
if (!(await canManageServer(db, getUser(req).userID, serverID))) {
|
|
162
|
+
res.sendStatus(403);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (buffer.byteLength > MAX_SERVER_ICON_BYTES) {
|
|
166
|
+
res.sendStatus(413);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const typeDetails = await fileTypeFromBuffer(buffer);
|
|
171
|
+
if (!ALLOWED_IMAGE_TYPES.includes(typeDetails?.mime ?? "")) {
|
|
172
|
+
res.status(400).json({
|
|
173
|
+
error: "Unsupported icon type. Use JPEG, PNG, GIF, APNG, AVIF, or WebP.",
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const existing = await db.retrieveServer(serverID);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
res.sendStatus(404);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const iconID = crypto.randomUUID();
|
|
185
|
+
const filePath = `${SERVER_ICON_DIR}/${iconID}`;
|
|
186
|
+
try {
|
|
187
|
+
await fsp.writeFile(filePath, buffer, { flag: "wx" });
|
|
188
|
+
const updated = await db.updateServer(serverID, { icon: iconID });
|
|
189
|
+
if (!updated) {
|
|
190
|
+
throw new Error("Server disappeared while its icon was updating.");
|
|
191
|
+
}
|
|
192
|
+
if (existing.icon) await deleteServerIconFile(existing.icon);
|
|
193
|
+
await notifyServerChange(serverID);
|
|
194
|
+
res.send(msgpack.encode(updated satisfies Server));
|
|
195
|
+
} catch (error: unknown) {
|
|
196
|
+
await fsp.unlink(filePath).catch(() => undefined);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|