/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2023 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { route } from "@spacebar/api";
import { Ban, DiscordApiErrors, GuildBanAddEvent, GuildBanRemoveEvent, Member, User, emitEvent } from "@spacebar/util";
import { Request, Response, Router } from "express";
import { HTTPError } from "lambert-server";
import { BanCreateSchema, BanRegistrySchema, GuildBanResponse, GuildBansResponse, PublicUser } from "@spacebar/schemas";
const router: Router = Router({ mergeParams: true });
/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */
router.get(
"/",
route({
permission: "BAN_MEMBERS",
responses: {
200: {
body: "GuildBansResponse",
},
403: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id } = req.params as { [key: string]: string };
let bans = await Ban.find({ where: { guild_id: guild_id } });
const promisesToAwait: Promise<PublicUser>[] = [];
const bansObj: GuildBansResponse = [];
bans = bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing
bans.forEach((ban) => {
promisesToAwait.push(User.getPublicUser(ban.user_id));
});
const bannedUsers = await Promise.all(promisesToAwait);
bans.forEach((ban, index) => {
const user = bannedUsers[index];
bansObj.push({
reason: ban.reason ?? null,
user: {
username: user.username,
discriminator: user.discriminator,
id: user.id,
avatar: user.avatar ?? null,
public_flags: user.public_flags,
},
});
});
return res.json(bansObj);
},
);
router.get(
"/search",
route({
permission: "BAN_MEMBERS",
query: {
query: {
type: "string",
description: "Query to match username(s) and display name(s) against (1-32 characters)",
required: true,
},
limit: {
type: "number",
description: "Max number of members to return (1-10, default 10)",
required: false,
},
},
responses: {
200: {
body: "GuildBansResponse",
},
403: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id } = req.params as { [key: string]: string };
const limit = Number(req.query.limit) || 10;
if (limit > 10 || limit < 1) throw new HTTPError("Limit must be between 1 and 10");
const query = String(req.query.query);
if (!query || query.trim().length === 0 || query.length > 32) {
throw new HTTPError("The query must be between 1 and 32 characters in length");
}
let bans = await Ban.createQueryBuilder("ban")
.leftJoinAndSelect("ban.user", "user")
.where("ban.guild_id = :guildId", { guildId: guild_id })
.andWhere("user.username LIKE :userName", {
userName: `%${query}%`,
})
.limit(limit)
.getMany();
bans = bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing
const bansObj: GuildBansResponse = bans.map((ban) => {
const user = ban.user;
return {
reason: ban.reason ?? null,
user: {
username: user.username,
discriminator: user.discriminator,
id: user.id,
avatar: user.avatar ?? null,
public_flags: user.public_flags,
},
};
});
return res.json(bansObj);
},
);
router.get(
"/:user_id",
route({
permission: "BAN_MEMBERS",
responses: {
200: {
body: "GuildBanResponse",
},
403: {
body: "APIErrorResponse",
},
404: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id, user_id } = req.params as { [key: string]: string };
const ban = (await Ban.findOneOrFail({
where: { guild_id: guild_id, user_id: user_id },
})) as BanRegistrySchema;
if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN;
// pretend self-bans don't exist to prevent victim chasing
const user = await User.getPublicUser(ban.user_id);
const banInfo: GuildBanResponse = {
user: {
username: user.username,
discriminator: user.discriminator,
id: user.id,
avatar: user.avatar ?? null,
public_flags: user.public_flags,
},
reason: ban.reason ?? null,
};
return res.json(banInfo);
},
);
router.put(
"/:user_id",
route({
requestBody: "BanCreateSchema",
permission: "BAN_MEMBERS",
responses: {
204: {},
400: {
body: "APIErrorResponse",
},
403: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id } = req.params as { [key: string]: string };
const banned_user_id = req.params.user_id as string;
const opts = req.body as BanCreateSchema;
let deleteMessagesMs = opts.delete_message_days
? (opts.delete_message_days as number) * 86400000
: opts.delete_message_seconds
? (opts.delete_message_seconds as number) * 1000
: 0;
if (deleteMessagesMs < 0) deleteMessagesMs = 0;
if (req.user_id === banned_user_id && banned_user_id === req.permission?.cache.guild?.owner_id)
throw new HTTPError("You are the guild owner, hence can't ban yourself", 403);
if (req.permission?.cache.guild?.owner_id === banned_user_id) throw new HTTPError("You can't ban the owner", 400);
const existingBan = await Ban.findOne({
where: { guild_id: guild_id, user_id: banned_user_id },
});
// Bans on already banned users are silently ignored
if (existingBan) return res.status(204).send();
const banned_user = await User.getPublicUser(banned_user_id);
const ban = Ban.create({
user_id: banned_user_id,
guild_id: guild_id,
executor_id: req.user_id,
reason: req.body.reason, // || otherwise empty
});
await Promise.all([
Member.removeFromGuild(banned_user_id, guild_id),
ban.save(),
emitEvent({
event: "GUILD_BAN_ADD",
data: {
guild_id: guild_id,
user: banned_user,
delete_message_secs: Math.floor(deleteMessagesMs / 1000),
},
guild_id: guild_id,
} satisfies GuildBanAddEvent),
]);
return res.status(204).send();
},
);
router.delete(
"/:user_id",
route({
permission: "BAN_MEMBERS",
responses: {
204: {},
403: {
body: "APIErrorResponse",
},
404: {
body: "APIErrorResponse",
},
},
}),
async (req: Request, res: Response) => {
const { guild_id, user_id } = req.params as { [key: string]: string };
await Ban.findOneOrFail({
where: { guild_id: guild_id, user_id: user_id },
});
const banned_user = await User.getPublicUser(user_id);
await Promise.all([
Ban.delete({
user_id: user_id,
guild_id,
}),
emitEvent({
event: "GUILD_BAN_REMOVE",
data: {
guild_id,
user: banned_user,
},
guild_id,
} satisfies GuildBanRemoveEvent),
]);
return res.status(204).send();
},
);
export default router;