@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.
@@ -0,0 +1,262 @@
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 {
8
+ Channel,
9
+ Permission,
10
+ User,
11
+ Server as VexServer,
12
+ } from "@vex-chat/types";
13
+ import type { Server } from "node:http";
14
+
15
+ import express from "express";
16
+
17
+ import { xSignKeyPair, XUtils } from "@vex-chat/crypto";
18
+
19
+ import { afterEach, describe, expect, it, vi } from "vitest";
20
+
21
+ import { Database } from "../Database.ts";
22
+ import { initApp } from "../server/index.ts";
23
+ import { signAuthJwt } from "../utils/authJwt.ts";
24
+ import { msgpack } from "../utils/msgpack.ts";
25
+
26
+ const testImage = XUtils.decodeBase64(
27
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRU5ErkJggg==",
28
+ );
29
+
30
+ const originalJwtSecret = process.env["JWT_SECRET"];
31
+ const user: User = {
32
+ lastSeen: new Date(0).toISOString(),
33
+ userID: "8d768e5c-55ec-4fe4-bcb2-26bdde8076d9",
34
+ username: "server-admin",
35
+ };
36
+ const member: User = {
37
+ lastSeen: new Date(0).toISOString(),
38
+ userID: "3e1225e2-88bb-469f-bef3-28531da7f97a",
39
+ username: "group-member",
40
+ };
41
+
42
+ describe("server management routes", () => {
43
+ afterEach(() => {
44
+ if (originalJwtSecret === undefined) {
45
+ delete process.env["JWT_SECRET"];
46
+ } else {
47
+ process.env["JWT_SECRET"] = originalJwtSecret;
48
+ }
49
+ });
50
+
51
+ it("renames servers and channels, manages icons, and preserves one channel", async () => {
52
+ process.env["JWT_SECRET"] = "server-management-test-secret";
53
+ const db = new Database({ dbType: "sqlite3mem" });
54
+ await ready(db);
55
+ await db["db"]
56
+ .insertInto("users")
57
+ .values(
58
+ [user, member].map((entry) => ({
59
+ ...entry,
60
+ passwordHash: "not-used-by-this-test",
61
+ })),
62
+ )
63
+ .execute();
64
+ const created = await db.createServer("Before", user.userID);
65
+ const memberPermission = await db.createPermission(
66
+ member.userID,
67
+ "server",
68
+ created.serverID,
69
+ 0,
70
+ );
71
+ const ownerPermission = (
72
+ await db.retrievePermissionsByResourceID(created.serverID)
73
+ ).find((permission) => permission.userID === user.userID);
74
+ expect(ownerPermission).toBeDefined();
75
+ const notify = vi.fn();
76
+ const app = express();
77
+ initApp(app, db, () => false, xSignKeyPair(), notify);
78
+ const listener = await listen(app);
79
+
80
+ try {
81
+ const origin = serverOrigin(listener);
82
+ const headers = {
83
+ Authorization: `Bearer ${signAuthJwt({ scope: "user", user }, "5m")}`,
84
+ "Content-Type": "application/msgpack",
85
+ };
86
+
87
+ const createServer = await fetch(`${origin}/servers`, {
88
+ body: msgpack.encode({ name: "Café 作戦" }),
89
+ headers,
90
+ method: "POST",
91
+ });
92
+ expect(createServer.status).toBe(200);
93
+ expect(await decode<VexServer>(createServer)).toMatchObject({
94
+ name: "Café 作戦",
95
+ });
96
+
97
+ const renameServer = await fetch(
98
+ `${origin}/server/${created.serverID}`,
99
+ {
100
+ body: msgpack.encode({ name: "After" }),
101
+ headers,
102
+ method: "PATCH",
103
+ },
104
+ );
105
+ expect(renameServer.status).toBe(200);
106
+ expect(await decode<VexServer>(renameServer)).toMatchObject({
107
+ name: "After",
108
+ serverID: created.serverID,
109
+ });
110
+
111
+ const promoteMember = await fetch(
112
+ `${origin}/permission/${memberPermission.permissionID}`,
113
+ {
114
+ body: msgpack.encode({ powerLevel: 50 }),
115
+ headers,
116
+ method: "PATCH",
117
+ },
118
+ );
119
+ expect(promoteMember.status).toBe(200);
120
+ expect(await decode<Permission>(promoteMember)).toMatchObject({
121
+ permissionID: memberPermission.permissionID,
122
+ powerLevel: 50,
123
+ });
124
+
125
+ const memberHeaders = {
126
+ Authorization: `Bearer ${signAuthJwt({ scope: "user", user: member }, "5m")}`,
127
+ "Content-Type": "application/msgpack",
128
+ };
129
+ const moderatorCannotChangeRoles = await fetch(
130
+ `${origin}/permission/${ownerPermission!.permissionID}`,
131
+ {
132
+ body: msgpack.encode({ powerLevel: 100 }),
133
+ headers: memberHeaders,
134
+ method: "PATCH",
135
+ },
136
+ );
137
+ expect(moderatorCannotChangeRoles.status).toBe(403);
138
+
139
+ const ownerCannotChangeOwnRole = await fetch(
140
+ `${origin}/permission/${ownerPermission!.permissionID}`,
141
+ {
142
+ body: msgpack.encode({ powerLevel: 50 }),
143
+ headers,
144
+ method: "PATCH",
145
+ },
146
+ );
147
+ expect(ownerCannotChangeOwnRole.status).toBe(400);
148
+
149
+ const unsupportedRole = await fetch(
150
+ `${origin}/permission/${memberPermission.permissionID}`,
151
+ {
152
+ body: msgpack.encode({ powerLevel: 25 }),
153
+ headers,
154
+ method: "PATCH",
155
+ },
156
+ );
157
+ expect(unsupportedRole.status).toBe(400);
158
+
159
+ const [general] = await db.retrieveChannels(created.serverID);
160
+ expect(general).toBeDefined();
161
+ const renameChannel = await fetch(
162
+ `${origin}/channel/${general!.channelID}`,
163
+ {
164
+ body: msgpack.encode({ name: "announcements" }),
165
+ headers,
166
+ method: "PATCH",
167
+ },
168
+ );
169
+ expect(renameChannel.status).toBe(200);
170
+ expect(await decode<Channel>(renameChannel)).toMatchObject({
171
+ name: "announcements",
172
+ });
173
+
174
+ const lastChannel = await fetch(
175
+ `${origin}/channel/${general!.channelID}`,
176
+ { headers, method: "DELETE" },
177
+ );
178
+ expect(lastChannel.status).toBe(409);
179
+
180
+ const extra = await db.createChannel("extra", created.serverID);
181
+ const deleteExtra = await fetch(
182
+ `${origin}/channel/${extra.channelID}`,
183
+ { headers, method: "DELETE" },
184
+ );
185
+ expect(deleteExtra.status).toBe(200);
186
+
187
+ const setIcon = await fetch(
188
+ `${origin}/server-icon/${created.serverID}/json`,
189
+ {
190
+ body: msgpack.encode({
191
+ file: XUtils.encodeBase64(testImage),
192
+ }),
193
+ headers,
194
+ method: "POST",
195
+ },
196
+ );
197
+ expect(setIcon.status).toBe(200);
198
+ const withIcon = await decode<VexServer>(setIcon);
199
+ expect(withIcon.icon).toEqual(expect.any(String));
200
+
201
+ const iconResponse = await fetch(
202
+ `${origin}/server-icon/${withIcon.icon!}`,
203
+ );
204
+ expect(iconResponse.status).toBe(200);
205
+ expect(iconResponse.headers.get("cache-control")).toContain(
206
+ "immutable",
207
+ );
208
+
209
+ const removeIcon = await fetch(
210
+ `${origin}/server-icon/${created.serverID}`,
211
+ { headers, method: "DELETE" },
212
+ );
213
+ expect(removeIcon.status).toBe(200);
214
+ expect((await decode<VexServer>(removeIcon)).icon).toBeUndefined();
215
+ expect(notify).toHaveBeenCalledWith(
216
+ user.userID,
217
+ "serverChange",
218
+ expect.any(String),
219
+ created.serverID,
220
+ );
221
+ } finally {
222
+ await close(listener);
223
+ await db.close();
224
+ }
225
+ });
226
+ });
227
+
228
+ async function close(server: Server): Promise<void> {
229
+ await new Promise<void>((resolve, reject) => {
230
+ server.close((error) => {
231
+ if (error) reject(error);
232
+ else resolve();
233
+ });
234
+ });
235
+ }
236
+
237
+ async function decode<T>(response: Response): Promise<T> {
238
+ return msgpack.decode(new Uint8Array(await response.arrayBuffer())) as T;
239
+ }
240
+
241
+ async function listen(app: express.Application): Promise<Server> {
242
+ return new Promise((resolve) => {
243
+ const server = app.listen(0, "127.0.0.1", () => {
244
+ resolve(server);
245
+ });
246
+ });
247
+ }
248
+
249
+ async function ready(db: Database): Promise<void> {
250
+ await new Promise<void>((resolve, reject) => {
251
+ db.once("ready", resolve);
252
+ db.once("error", reject);
253
+ });
254
+ }
255
+
256
+ function serverOrigin(server: Server): string {
257
+ const address = server.address();
258
+ if (!address || typeof address === "string") {
259
+ throw new Error("Expected TCP listener.");
260
+ }
261
+ return `http://127.0.0.1:${String(address.port)}`;
262
+ }
@@ -54,6 +54,7 @@ export const getAvatarRouter = () => {
54
54
  }
55
55
  res.set("Content-type", typeDetails.mime);
56
56
  res.set("Cache-control", "public, max-age=31536000");
57
+ res.set("Cross-Origin-Resource-Policy", "cross-origin");
57
58
 
58
59
  const stream = fs.createReadStream(filePath);
59
60
  stream.on("error", (_err) => {
@@ -100,7 +101,7 @@ export const getAvatarRouter = () => {
100
101
  if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
101
102
  res.status(400).send({
102
103
  error:
103
- "Unsupported file type. Expected jpeg, png, gif, apng, avif, or svg but received " +
104
+ "Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
104
105
  String(mimeType?.ext),
105
106
  });
106
107
  return;
@@ -143,7 +144,7 @@ export const getAvatarRouter = () => {
143
144
  if (!ALLOWED_IMAGE_TYPES.includes(mimeType?.mime || "no/type")) {
144
145
  res.status(400).send({
145
146
  error:
146
- "Unsupported file type. Expected jpeg, png, gif, apng, avif, or svg but received " +
147
+ "Unsupported file type. Expected jpeg, png, gif, apng, avif, or webp but received " +
147
148
  String(mimeType?.ext),
148
149
  });
149
150
  return;