@vex-chat/spire 4.0.0 → 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.
@@ -53,6 +53,7 @@ import {
53
53
  userHasPermission,
54
54
  } from "./permissions.ts";
55
55
  import { globalLimiter, keyBundleLimiter, uploadLimiter } from "./rateLimit.ts";
56
+ import { deleteServerIconFile, getServerIconRouter } from "./serverIcon.ts";
56
57
  import { getUserRouter } from "./user.ts";
57
58
  import { censorUser, getParam, getUser } from "./utils.ts";
58
59
  import { getWellKnownRouter } from "./wellKnown.ts";
@@ -66,6 +67,7 @@ export const ALLOWED_IMAGE_TYPES = [
66
67
  "image/gif",
67
68
  "image/apng",
68
69
  "image/avif",
70
+ "image/webp",
69
71
  ];
70
72
 
71
73
  // ── Zod schemas for trust-boundary validation ──────────────────────────
@@ -75,7 +77,15 @@ const invitePayload = z.object({
75
77
  });
76
78
 
77
79
  const channelPayload = z.object({
78
- name: z.string().min(1).max(255),
80
+ name: z.string().trim().min(1).max(100),
81
+ });
82
+
83
+ const serverUpdatePayload = z.object({
84
+ name: z.string().trim().min(1).max(100),
85
+ });
86
+
87
+ const permissionRolePayload = z.object({
88
+ powerLevel: z.union([z.literal(0), z.literal(50), z.literal(100)]),
79
89
  });
80
90
 
81
91
  const deviceListPayload = z.array(z.string().min(1).max(128)).max(256);
@@ -283,7 +293,7 @@ export const msgpackParser: express.RequestHandler = (req, res, next) => {
283
293
  next();
284
294
  };
285
295
 
286
- const directories = ["files", "avatars"];
296
+ const directories = ["files", "avatars", "server-icons"];
287
297
  for (const dir of directories) {
288
298
  if (!fs.existsSync(dir)) {
289
299
  fs.mkdirSync(dir);
@@ -304,6 +314,20 @@ export const initApp = (
304
314
  ) => void,
305
315
  disconnectDevices?: (deviceIDs: string[]) => void,
306
316
  ) => {
317
+ const notifyServerChange = async (
318
+ serverID: string,
319
+ additionalUserIDs: readonly string[] = [],
320
+ ): Promise<void> => {
321
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
322
+ const userIDs = new Set([
323
+ ...affectedUsers.map((user) => user.userID),
324
+ ...additionalUserIDs,
325
+ ]);
326
+ for (const userID of userIDs) {
327
+ notify(userID, "serverChange", crypto.randomUUID(), serverID);
328
+ }
329
+ };
330
+
307
331
  // INIT ROUTERS
308
332
  const userRouter = getUserRouter(
309
333
  db,
@@ -318,6 +342,7 @@ export const initApp = (
318
342
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
319
343
  const passkeyRouter = getPasskeyRouter(db);
320
344
  const passwordRouter = getPasswordRouter(db);
345
+ const serverIconRouter = getServerIconRouter(db, notifyServerChange);
321
346
  const passkeyDeviceRouter = getPasskeyDeviceRouter(
322
347
  db,
323
348
  notify,
@@ -419,6 +444,54 @@ export const initApp = (
419
444
  }
420
445
  });
421
446
 
447
+ api.patch("/server/:id", protect, async (req, res) => {
448
+ const parsed = serverUpdatePayload.safeParse(req.body);
449
+ if (!parsed.success) {
450
+ res.status(400).json({
451
+ error: "Invalid server update",
452
+ issues: parsed.error.issues,
453
+ });
454
+ return;
455
+ }
456
+
457
+ const serverID = getParam(req, "id");
458
+ const permissions = await db.retrievePermissions(
459
+ getUser(req).userID,
460
+ "server",
461
+ );
462
+ if (!hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
463
+ res.sendStatus(403);
464
+ return;
465
+ }
466
+
467
+ const server = await db.updateServer(serverID, parsed.data);
468
+ if (!server) {
469
+ res.sendStatus(404);
470
+ return;
471
+ }
472
+ await notifyServerChange(serverID);
473
+ res.send(msgpack.encode(server));
474
+ });
475
+
476
+ api.post("/servers", protect, async (req, res) => {
477
+ const parsed = serverUpdatePayload.safeParse(req.body);
478
+ if (!parsed.success) {
479
+ res.status(400).json({
480
+ error: "Invalid server",
481
+ issues: parsed.error.issues,
482
+ });
483
+ return;
484
+ }
485
+
486
+ const server = await db.createServer(
487
+ parsed.data.name,
488
+ getUser(req).userID,
489
+ );
490
+ res.send(msgpack.encode(server));
491
+ });
492
+
493
+ // Compatibility route for clients released before server names moved into
494
+ // the request body. New clients use POST /servers so Unicode names work.
422
495
  api.post("/server/:name", protect, async (req, res) => {
423
496
  const userDetails = getUser(req);
424
497
  const encodedName = getParam(req, "name");
@@ -537,11 +610,22 @@ export const initApp = (
537
610
  "server",
538
611
  );
539
612
  if (hasPermission(permissions, serverID, POWER_LEVELS.DELETE)) {
613
+ const server = await db.retrieveServer(serverID);
614
+ if (!server) {
615
+ res.sendStatus(404);
616
+ return;
617
+ }
618
+ const affectedUsers = await db.retrieveAffectedUsers(serverID);
540
619
  await db.deleteServer(serverID);
620
+ if (server.icon) await deleteServerIconFile(server.icon);
621
+ await notifyServerChange(
622
+ serverID,
623
+ affectedUsers.map((user) => user.userID),
624
+ );
541
625
  res.sendStatus(200);
542
626
  return;
543
627
  }
544
- res.sendStatus(401);
628
+ res.sendStatus(403);
545
629
  });
546
630
 
547
631
  api.post("/server/:id/channels", protect, async (req, res) => {
@@ -564,20 +648,10 @@ export const initApp = (
564
648
  if (hasPermission(permissions, serverID, POWER_LEVELS.CREATE)) {
565
649
  const channel = await db.createChannel(name, serverID);
566
650
  res.send(msgpack.encode(channel));
567
-
568
- const affectedUsers = await db.retrieveAffectedUsers(serverID);
569
- // tell everyone about server change
570
- for (const user of affectedUsers) {
571
- notify(
572
- user.userID,
573
- "serverChange",
574
- crypto.randomUUID(),
575
- serverID,
576
- );
577
- }
651
+ await notifyServerChange(serverID);
578
652
  return;
579
653
  }
580
- res.sendStatus(401);
654
+ res.sendStatus(403);
581
655
  });
582
656
 
583
657
  api.get("/server/:id/channels", protect, async (req, res) => {
@@ -623,6 +697,42 @@ export const initApp = (
623
697
  res.send(msgpack.encode(permissions));
624
698
  });
625
699
 
700
+ api.patch("/channel/:id", protect, async (req, res) => {
701
+ const parsed = channelPayload.safeParse(req.body);
702
+ if (!parsed.success) {
703
+ res.status(400).json({
704
+ error: "Invalid channel update",
705
+ issues: parsed.error.issues,
706
+ });
707
+ return;
708
+ }
709
+
710
+ const channelID = getParam(req, "id");
711
+ const channel = await db.retrieveChannel(channelID);
712
+ if (!channel) {
713
+ res.sendStatus(404);
714
+ return;
715
+ }
716
+ const permissions = await db.retrievePermissions(
717
+ getUser(req).userID,
718
+ "server",
719
+ );
720
+ if (
721
+ !hasPermission(permissions, channel.serverID, POWER_LEVELS.DELETE)
722
+ ) {
723
+ res.sendStatus(403);
724
+ return;
725
+ }
726
+
727
+ const updated = await db.updateChannel(channelID, parsed.data.name);
728
+ if (!updated) {
729
+ res.sendStatus(404);
730
+ return;
731
+ }
732
+ await notifyServerChange(channel.serverID);
733
+ res.send(msgpack.encode(updated));
734
+ });
735
+
626
736
  api.delete("/channel/:id", protect, async (req, res) => {
627
737
  const channelID = getParam(req, "id");
628
738
  const userDetails = getUser(req);
@@ -643,26 +753,19 @@ export const initApp = (
643
753
  permission.resourceID === channel.serverID &&
644
754
  permission.powerLevel >= POWER_LEVELS.DELETE
645
755
  ) {
646
- await db.deleteChannel(channelID);
647
-
648
- res.sendStatus(200);
649
-
650
- const affectedUsers = await db.retrieveAffectedUsers(
651
- channel.serverID,
652
- );
653
- // tell everyone about server change
654
- for (const user of affectedUsers) {
655
- notify(
656
- user.userID,
657
- "serverChange",
658
- crypto.randomUUID(),
659
- channel.serverID,
660
- );
756
+ const deleted = await db.deleteChannelIfNotLast(channelID);
757
+ if (!deleted) {
758
+ res.status(409).json({
759
+ error: "A server must have at least one channel.",
760
+ });
761
+ return;
661
762
  }
763
+ await notifyServerChange(channel.serverID);
764
+ res.sendStatus(200);
662
765
  return;
663
766
  }
664
767
  }
665
- res.sendStatus(401);
768
+ res.sendStatus(403);
666
769
  });
667
770
 
668
771
  api.get("/channel/:id", protect, async (req, res) => {
@@ -682,6 +785,56 @@ export const initApp = (
682
785
  }
683
786
  });
684
787
 
788
+ api.patch("/permission/:permissionID", protect, async (req, res) => {
789
+ const parsed = permissionRolePayload.safeParse(req.body);
790
+ if (!parsed.success) {
791
+ res.status(400).json({
792
+ error: "Invalid role",
793
+ issues: parsed.error.issues,
794
+ });
795
+ return;
796
+ }
797
+
798
+ const target = await db.retrievePermission(
799
+ getParam(req, "permissionID"),
800
+ );
801
+ if (!target) {
802
+ res.sendStatus(404);
803
+ return;
804
+ }
805
+ if (target.resourceType !== "server") {
806
+ res.sendStatus(400);
807
+ return;
808
+ }
809
+
810
+ const actor = getUser(req);
811
+ if (target.userID === actor.userID) {
812
+ res.status(400).json({
813
+ error: "Use another owner to change your role.",
814
+ });
815
+ return;
816
+ }
817
+ const actorPermissions = await db.retrievePermissions(
818
+ actor.userID,
819
+ "server",
820
+ );
821
+ if (!hasPermission(actorPermissions, target.resourceID, 100)) {
822
+ res.sendStatus(403);
823
+ return;
824
+ }
825
+
826
+ const updated = await db.updatePermissionPowerLevel(
827
+ target.permissionID,
828
+ parsed.data.powerLevel,
829
+ );
830
+ if (!updated) {
831
+ res.sendStatus(404);
832
+ return;
833
+ }
834
+ await notifyServerChange(target.resourceID);
835
+ res.send(msgpack.encode(updated));
836
+ });
837
+
685
838
  api.delete("/permission/:permissionID", protect, async (req, res) => {
686
839
  const permissionID = getParam(req, "permissionID");
687
840
  const userDetails = getUser(req);
@@ -704,7 +857,32 @@ export const initApp = (
704
857
  POWER_LEVELS.DELETE,
705
858
  )
706
859
  ) {
860
+ if (
861
+ permToDelete.resourceType === "server" &&
862
+ permToDelete.powerLevel >= 100
863
+ ) {
864
+ const resourcePermissions =
865
+ await db.retrievePermissionsByResourceID(
866
+ permToDelete.resourceID,
867
+ );
868
+ const hasAnotherOwner = resourcePermissions.some(
869
+ (permission) =>
870
+ permission.permissionID !== permToDelete.permissionID &&
871
+ permission.powerLevel >= 100,
872
+ );
873
+ if (!hasAnotherOwner) {
874
+ res.status(409).json({
875
+ error: "Delete the server instead of leaving it without an owner.",
876
+ });
877
+ return;
878
+ }
879
+ }
707
880
  await db.deletePermission(permToDelete.permissionID);
881
+ if (permToDelete.resourceType === "server") {
882
+ await notifyServerChange(permToDelete.resourceID, [
883
+ permToDelete.userID,
884
+ ]);
885
+ }
708
886
  res.sendStatus(200);
709
887
  return;
710
888
  }
@@ -1199,6 +1377,8 @@ export const initApp = (
1199
1377
 
1200
1378
  api.use("/avatar", avatarRouter);
1201
1379
 
1380
+ api.use("/server-icon", serverIconRouter);
1381
+
1202
1382
  api.use("/invite", inviteRouter);
1203
1383
 
1204
1384
  setupDocs(api);