summary refs log tree commit diff
path: root/src/api
diff options
context:
space:
mode:
authorCyber <mateusz.hf18@gmail.com>2025-12-15 08:29:43 +0100
committerGitHub <noreply@github.com>2025-12-15 08:29:43 +0100
commita583d85d443a3af7ebbd3ab87f887748f1439d25 (patch)
tree4da1cc7dc9514969825ef003706849e58840e34f /src/api
parentfix: check if event is "opened" or "closed" (diff)
parentfix cors (diff)
downloadserver-ts-a583d85d443a3af7ebbd3ab87f887748f1439d25.tar.xz
Merge branch 'spacebarchat:master' into fix/gh-webhook-events-more
Diffstat (limited to 'src/api')
-rw-r--r--src/api/middlewares/Authentication.ts16
-rw-r--r--src/api/middlewares/CORS.ts14
-rw-r--r--src/api/middlewares/RateLimit.ts48
-rw-r--r--src/api/routes/auth/forgot.ts15
-rw-r--r--src/api/routes/auth/location-metadata.ts4
-rw-r--r--src/api/routes/auth/login.ts45
-rw-r--r--src/api/routes/auth/register.ts8
-rw-r--r--src/api/routes/auth/reset.ts2
-rw-r--r--src/api/routes/auth/verify/index.ts17
-rw-r--r--src/api/routes/channels/#channel_id/messages/index.ts2
-rw-r--r--src/api/routes/guilds/#guild_id/bans.ts17
-rw-r--r--src/api/routes/guilds/#guild_id/bulk-ban.ts4
-rw-r--r--src/api/routes/guilds/#guild_id/regions.ts9
-rw-r--r--src/api/routes/invites/index.ts4
-rw-r--r--src/api/routes/oauth2/authorize.ts49
-rw-r--r--src/api/routes/users/#user_id/delete.ts146
-rw-r--r--src/api/routes/voice/regions.ts4
-rw-r--r--src/api/util/utility/ipAddress.ts33
18 files changed, 238 insertions, 199 deletions
diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts

index 4bb809a67..2dd992ee3 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts
@@ -70,6 +70,7 @@ declare global { user_bot: boolean; token: { id: string; iat: number }; rights: Rights; + fingerprint?: string; } } } @@ -77,6 +78,15 @@ declare global { export async function Authentication(req: Request, res: Response, next: NextFunction) { if (req.method === "OPTIONS") return res.sendStatus(204); const url = req.url.replace(API_PREFIX, ""); + + if (req.headers.cookie?.split("; ").find((x) => x.startsWith("__sb_sessid="))) + req.fingerprint = req.headers.cookie + .split("; ") + .find((x) => x.startsWith("__sb_sessid="))! + .split("=")[1]; + // for some reason we need to require here, else the openapi generator fails with "route is not a function" + else res.setHeader("Set-Cookie", `__sb_sessid=${req.fingerprint = (await require("../util")).randomString(32)}; Secure; HttpOnly; SameSite=None; Path=/`); + if ( NO_AUTHORIZATION_ROUTES.some((x) => { if (typeof x !== "string") { @@ -102,10 +112,14 @@ export async function Authentication(req: Request, res: Response, next: NextFunc }) ) return next(); + if (!req.headers.authorization) return next(new HTTPError("Missing Authorization Header", 401)); try { - const { decoded, user } = await checkToken(req.headers.authorization); + const { decoded, user } = await checkToken(req.headers.authorization, { + ipAddress: req.ip, + fingerprint: req.fingerprint, + }); req.token = decoded; req.user_id = decoded.id; diff --git a/src/api/middlewares/CORS.ts b/src/api/middlewares/CORS.ts
index ca6abb82d..34532d3f5 100644 --- a/src/api/middlewares/CORS.ts +++ b/src/api/middlewares/CORS.ts
@@ -21,20 +21,16 @@ import { NextFunction, Request, Response } from "express"; // TODO: config settings export function CORS(req: Request, res: Response, next: NextFunction) { - res.set("Access-Control-Allow-Origin", "*"); + res.set("Access-Control-Allow-Credentials", "true"); + res.set("Access-Control-Allow-Headers", req.header("Access-Control-Request-Headers") || "*"); + res.set("Access-Control-Allow-Methods", req.header("Access-Control-Request-Method") || "*"); + res.set("Access-Control-Allow-Origin", req.header("Origin") ?? "*"); + res.set("Access-Control-Max-Age", "5"); // dont make it too long so we can change it dynamically // TODO: use better CSP res.set( "Content-security-policy", "default-src * data: blob: filesystem: about: ws: wss: 'unsafe-inline' 'unsafe-eval'; script-src * data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src * data: blob: 'unsafe-inline'; img-src * data: blob: 'unsafe-inline'; frame-src * data: blob: ; style-src * data: blob: 'unsafe-inline'; font-src * data: blob: 'unsafe-inline';", ); - res.set( - "Access-Control-Allow-Headers", - req.header("Access-Control-Request-Headers") || "*", - ); - res.set( - "Access-Control-Allow-Methods", - req.header("Access-Control-Request-Methods") || "*", - ); if (req.method === "OPTIONS") { res.status(204).end(); diff --git a/src/api/middlewares/RateLimit.ts b/src/api/middlewares/RateLimit.ts
index 81ccbc515..1a41ad4f6 100644 --- a/src/api/middlewares/RateLimit.ts +++ b/src/api/middlewares/RateLimit.ts
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress } from "@spacebar/api"; import { Config, getRights, listenEvent } from "@spacebar/util"; import { NextFunction, Request, Response, Router } from "express"; import { API_PREFIX_TRAILING_SLASH } from "./Authentication"; @@ -65,21 +64,14 @@ export default function rateLimit(opts: { if (rights.has("BYPASS_RATE_LIMITS")) return next(); } - const bucket_id = - opts.bucket || - req.originalUrl.replace(API_PREFIX_TRAILING_SLASH, ""); - let executor_id = getIpAdress(req); + const bucket_id = opts.bucket || req.originalUrl.replace(API_PREFIX_TRAILING_SLASH, ""); + let executor_id = req.ip || "127.0.0.1"; if (!opts.onlyIp && req.user_id) executor_id = req.user_id; let max_hits = opts.count; if (opts.bot && req.user_bot) max_hits = opts.bot; - if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) - max_hits = opts.GET; - else if ( - opts.MODIFY && - ["POST", "DELETE", "PATCH", "PUT"].includes(req.method) - ) - max_hits = opts.MODIFY; + if (opts.GET && ["GET", "OPTIONS", "HEAD"].includes(req.method)) max_hits = opts.GET; + else if (opts.MODIFY && ["POST", "DELETE", "PATCH", "PUT"].includes(req.method)) max_hits = opts.MODIFY; const offender = Cache.get(executor_id + bucket_id); @@ -104,18 +96,13 @@ export default function rateLimit(opts: { } res.set("X-RateLimit-Reset", `${reset}`); - res.set( - "X-RateLimit-Reset-After", - `${Math.max(0, Math.ceil(resetAfterSec))}`, - ); + res.set("X-RateLimit-Reset-After", `${Math.max(0, Math.ceil(resetAfterSec))}`); if (offender.blocked) { const global = bucket_id === "global"; // each block violation pushes the expiry one full window further reset += opts.window * 1000; - offender.expires_at = new Date( - offender.expires_at.getTime() + opts.window * 1000, - ); + offender.expires_at = new Date(offender.expires_at.getTime() + opts.window * 1000); resetAfterMs = reset - Date.now(); resetAfterSec = Math.ceil(resetAfterMs / 1000); @@ -129,10 +116,7 @@ export default function rateLimit(opts: { res .status(429) .set("X-RateLimit-Remaining", "0") - .set( - "Retry-After", - `${Math.max(0, Math.ceil(resetAfterSec))}`, - ) + .set("Retry-After", `${Math.max(0, Math.ceil(resetAfterSec))}`) // TODO: error rate limit message translation .send({ message: "You are being rate limited.", @@ -156,11 +140,7 @@ export default function rateLimit(opts: { // check if error and increment error rate limit if (res.statusCode >= 400 && opts.error) { return hitRoute(hitRouteOpts); - } else if ( - res.statusCode >= 200 && - res.statusCode < 300 && - opts.success - ) { + } else if (res.statusCode >= 200 && res.statusCode < 300 && opts.success) { return hitRoute(hitRouteOpts); } }); @@ -213,18 +193,10 @@ export async function initRateLimits(app: Router) { app.use("/webhooks/:webhook_id", rateLimit(routes.webhook)); app.use("/channels/:channel_id", rateLimit(routes.channel)); app.use("/auth/login", rateLimit(routes.auth.login)); - app.use( - "/auth/register", - rateLimit({ onlyIp: true, success: true, ...routes.auth.register }), - ); + app.use("/auth/register", rateLimit({ onlyIp: true, success: true, ...routes.auth.register })); } -async function hitRoute(opts: { - executor_id: string; - bucket_id: string; - max_hits: number; - window: number; -}) { +async function hitRoute(opts: { executor_id: string; bucket_id: string; max_hits: number; window: number }) { const id = opts.executor_id + opts.bucket_id; let limit = Cache.get(id); if (!limit) { diff --git a/src/api/routes/auth/forgot.ts b/src/api/routes/auth/forgot.ts
index 12bc9cb07..6e613820f 100644 --- a/src/api/routes/auth/forgot.ts +++ b/src/api/routes/auth/forgot.ts
@@ -16,10 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route, verifyCaptcha } from "@spacebar/api"; +import { route, verifyCaptcha } from "@spacebar/api"; import { Config, Email, User } from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { ForgotPasswordSchema } from "@spacebar/schemas" +import { ForgotPasswordSchema } from "@spacebar/schemas"; const router = Router({ mergeParams: true }); router.post( @@ -38,10 +38,7 @@ router.post( const config = Config.get(); - if ( - config.passwordReset.requireCaptcha && - config.security.captcha.enabled - ) { + if (config.passwordReset.requireCaptcha && config.security.captcha.enabled) { const { sitekey, service } = config.security.captcha; if (!captcha_key) { return res.status(400).json({ @@ -51,7 +48,7 @@ router.post( }); } - const ip = getIpAdress(req); + const ip = req.ip; const verify = await verifyCaptcha(captcha_key, ip); if (!verify.success) { return res.status(400).json({ @@ -71,9 +68,7 @@ router.post( if (user && user.email) { Email.sendResetPassword(user, user.email).catch((e) => { - console.error( - `Failed to send password reset email to ${user.username}#${user.discriminator} (${user.id}): ${e}`, - ); + console.error(`Failed to send password reset email to ${user.username}#${user.discriminator} (${user.id}): ${e}`); }); } }, diff --git a/src/api/routes/auth/location-metadata.ts b/src/api/routes/auth/location-metadata.ts
index a08c98abe..92f47909d 100644 --- a/src/api/routes/auth/location-metadata.ts +++ b/src/api/routes/auth/location-metadata.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route } from "@spacebar/api"; +import { route } from "@spacebar/api"; import { IpDataClient } from "@spacebar/util"; import { Request, Response, Router } from "express"; const router = Router({ mergeParams: true }); @@ -33,7 +33,7 @@ router.get( async (req: Request, res: Response) => { //TODO //Note: It's most likely related to legal. At the moment Discord hasn't finished this too - const country_code = (await IpDataClient.getIpInfo(getIpAdress(req)))?.country_code; + const country_code = (await IpDataClient.getIpInfo(req.ip!))?.country_code; res.json({ consent_required: false, country_code: country_code, diff --git a/src/api/routes/auth/login.ts b/src/api/routes/auth/login.ts
index 06e3fe43e..c8ef62731 100644 --- a/src/api/routes/auth/login.ts +++ b/src/api/routes/auth/login.ts
@@ -16,19 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route, verifyCaptcha } from "@spacebar/api"; -import { - Config, - FieldErrors, - User, - WebAuthn, - generateToken, - generateWebAuthnTicket, -} from "@spacebar/util"; +import { route, verifyCaptcha } from "@spacebar/api"; +import { Config, FieldErrors, User, WebAuthn, generateToken, generateWebAuthnTicket } from "@spacebar/util"; import bcrypt from "bcrypt"; import crypto from "crypto"; import { Request, Response, Router } from "express"; -import { LoginSchema } from "@spacebar/schemas" +import { LoginSchema } from "@spacebar/schemas"; const router: Router = Router({ mergeParams: true }); export default router; @@ -61,7 +54,7 @@ router.post( }); } - const ip = getIpAdress(req); + const ip = req.ip; const verify = await verifyCaptcha(captcha_key, ip); if (!verify.success) { return res.status(400).json({ @@ -74,17 +67,7 @@ router.post( const user = await User.findOneOrFail({ where: [{ phone: login }, { email: login }], - select: [ - "data", - "id", - "disabled", - "deleted", - "totp_secret", - "mfa_enabled", - "webauthn_enabled", - "security_keys", - "verified", - ], + select: ["data", "id", "disabled", "deleted", "totp_secret", "mfa_enabled", "webauthn_enabled", "security_keys", "verified"], relations: ["security_keys", "settings"], }).catch(() => { throw FieldErrors({ @@ -100,10 +83,7 @@ router.post( }); // the salt is saved in the password refer to bcrypt docs - const same_password = await bcrypt.compare( - password, - user.data.hash || "", - ); + const same_password = await bcrypt.compare(password, user.data.hash || ""); if (!same_password) { throw FieldErrors({ login: { @@ -122,8 +102,7 @@ router.post( throw FieldErrors({ login: { code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL", - message: - "Email verification is required, please check your email.", + message: "Email verification is required, please check your email.", }, }); } @@ -152,9 +131,7 @@ router.post( const challenge = JSON.stringify({ publicKey: { ...options, - challenge: Buffer.from(options.challenge).toString( - "base64", - ), + challenge: Buffer.from(options.challenge).toString("base64"), allowCredentials: user.security_keys.map((x) => ({ id: x.key_id, type: "public-key", @@ -178,10 +155,8 @@ router.post( if (undelete) { // undelete refers to un'disable' here - if (user.disabled) - await User.update({ id: user.id }, { disabled: false }); - if (user.deleted) - await User.update({ id: user.id }, { deleted: false }); + if (user.disabled) await User.update({ id: user.id }, { disabled: false }); + if (user.deleted) await User.update({ id: user.id }, { deleted: false }); } else { if (user.deleted) return res.status(400).json({ diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts
index 418f0a529..d4b061be2 100644 --- a/src/api/routes/auth/register.ts +++ b/src/api/routes/auth/register.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route, verifyCaptcha } from "@spacebar/api"; +import { route, verifyCaptcha } from "@spacebar/api"; import { Config, FieldErrors, Invite, User, ValidRegistrationToken, generateToken, IpDataClient, AbuseIpDbClient } from "@spacebar/util"; import bcrypt from "bcrypt"; import { Request, Response, Router } from "express"; @@ -38,7 +38,7 @@ router.post( async (req: Request, res: Response) => { const body = req.body as RegisterSchema; const { register, security, limits } = Config.get(); - const ip = getIpAdress(req); + const ip = req.ip!; // Reg tokens // They're a one time use token that bypasses registration limits ( rates, disabled reg, etc ) @@ -143,7 +143,7 @@ router.post( const ipData = await IpDataClient.getIpInfo(ip); if (ipData) { - if(!ipData.threat) { + if (!ipData.threat) { console.log("Invalid IPData.co response, missing threat field", ipData); } const categories = Object.entries(ipData.threat) @@ -287,7 +287,7 @@ router.post( }, })) >= limits.absoluteRate.register.limit ) { - console.log(`Global register ratelimit exceeded for ${getIpAdress(req)}, ${req.body.username}, ${req.body.invite || "No invite given"}`); + console.log(`Global register ratelimit exceeded for ${req.ip}, ${req.body.username}, ${req.body.invite || "No invite given"}`); throw FieldErrors({ email: { code: "TOO_MANY_REGISTRATIONS", diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts
index b14eb3791..5cb5690d3 100644 --- a/src/api/routes/auth/reset.ts +++ b/src/api/routes/auth/reset.ts
@@ -51,6 +51,8 @@ router.post( try { const userTokenData = await checkToken(token, { select: ["email"], + fingerprint: req.fingerprint, + ipAddress: req.ip }); user = userTokenData.user; } catch { diff --git a/src/api/routes/auth/verify/index.ts b/src/api/routes/auth/verify/index.ts
index b85120d84..8c636196e 100644 --- a/src/api/routes/auth/verify/index.ts +++ b/src/api/routes/auth/verify/index.ts
@@ -16,14 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route, verifyCaptcha } from "@spacebar/api"; -import { - checkToken, - Config, - FieldErrors, - generateToken, - User, -} from "@spacebar/util"; +import { route, verifyCaptcha } from "@spacebar/api"; +import { checkToken, Config, FieldErrors, generateToken, User } from "@spacebar/util"; import { Request, Response, Router } from "express"; const router = Router({ mergeParams: true }); @@ -67,7 +61,7 @@ router.post( }); } - const ip = getIpAdress(req); + const ip = req.ip; const verify = await verifyCaptcha(captcha_key, ip); if (!verify.success) { return res.status(400).json({ @@ -81,7 +75,10 @@ router.post( let user; try { - const userTokenData = await checkToken(token); + const userTokenData = await checkToken(token, { + fingerprint: req.fingerprint, + ipAddress: req.ip + }); user = userTokenData.user; } catch { throw FieldErrors({ diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts
index 8790241d8..43c4984e0 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -548,7 +548,7 @@ router.delete( // TODO: handle other read state types if (body.read_state_type != ReadStateType.CHANNEL) return res.status(204).send(); - const readState = await ReadState.findOne({where: {channel_id}}); + const readState = await ReadState.findOne({ where: { channel_id, user_id: req.user_id } }); if (readState) { await readState.remove(); } diff --git a/src/api/routes/guilds/#guild_id/bans.ts b/src/api/routes/guilds/#guild_id/bans.ts
index 1274c195d..39eee34aa 100644 --- a/src/api/routes/guilds/#guild_id/bans.ts +++ b/src/api/routes/guilds/#guild_id/bans.ts
@@ -16,11 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route } from "@spacebar/api"; +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 { APIBansArray, BanRegistrySchema, GuildBansResponse } from "@spacebar/schemas"; +import { APIBansArray, BanCreateSchema, BanRegistrySchema, GuildBansResponse } from "@spacebar/schemas"; const router: Router = Router({ mergeParams: true }); @@ -198,6 +198,15 @@ router.put( async (req: Request, res: Response) => { const { guild_id } = req.params; const banned_user_id = req.params.user_id; + 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); @@ -207,6 +216,7 @@ router.put( 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(); @@ -227,6 +237,7 @@ router.put( data: { guild_id: guild_id, user: banned_user.toPublicUser(), + delete_message_secs: Math.floor(deleteMessagesMs / 1000), }, guild_id: guild_id, } as GuildBanAddEvent), @@ -279,4 +290,4 @@ router.delete( }, ); -export default router; +export default router; \ No newline at end of file diff --git a/src/api/routes/guilds/#guild_id/bulk-ban.ts b/src/api/routes/guilds/#guild_id/bulk-ban.ts
index 3d3ed1c39..a564e2d48 100644 --- a/src/api/routes/guilds/#guild_id/bulk-ban.ts +++ b/src/api/routes/guilds/#guild_id/bulk-ban.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route } from "@spacebar/api"; +import { route } from "@spacebar/api"; import { Ban, DiscordApiErrors, GuildBanAddEvent, Member, User, emitEvent } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; @@ -81,7 +81,7 @@ router.post( const ban = Ban.create({ user_id: banned_user_id, guild_id: guild_id, - ip: getIpAdress(req), + ip: req.ip, executor_id: req.user_id, reason: req.body.reason, // || otherwise empty }); diff --git a/src/api/routes/guilds/#guild_id/regions.ts b/src/api/routes/guilds/#guild_id/regions.ts
index a7fad818d..a3c5a81b6 100644 --- a/src/api/routes/guilds/#guild_id/regions.ts +++ b/src/api/routes/guilds/#guild_id/regions.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, getVoiceRegions, route } from "@spacebar/api"; +import { getVoiceRegions, route } from "@spacebar/api"; import { Guild } from "@spacebar/util"; import { Request, Response, Router } from "express"; @@ -38,12 +38,7 @@ router.get( const { guild_id } = req.params; const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); //TODO we should use an enum for guild's features and not hardcoded strings - return res.json( - await getVoiceRegions( - getIpAdress(req), - guild.features.includes("VIP_REGIONS"), - ), - ); + return res.json(await getVoiceRegions(req.ip!, guild.features.includes("VIP_REGIONS"))); }, ); diff --git a/src/api/routes/invites/index.ts b/src/api/routes/invites/index.ts
index c77d36eaa..87715f35e 100644 --- a/src/api/routes/invites/index.ts +++ b/src/api/routes/invites/index.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, route } from "@spacebar/api"; +import { route } from "@spacebar/api"; import { Ban, DiscordApiErrors, emitEvent, getPermission, Guild, Invite, InviteDeleteEvent, PublicInviteRelation, User } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; @@ -83,7 +83,7 @@ router.post( const ban = await Ban.findOne({ where: [ { guild_id: guild_id, user_id: req.user_id }, - { guild_id: guild_id, ip: getIpAdress(req) }, + { guild_id: guild_id, ip: req.ip }, ], }); diff --git a/src/api/routes/oauth2/authorize.ts b/src/api/routes/oauth2/authorize.ts
index a14d57057..4084a059c 100644 --- a/src/api/routes/oauth2/authorize.ts +++ b/src/api/routes/oauth2/authorize.ts
@@ -17,18 +17,9 @@ */ import { route } from "@spacebar/api"; -import { - ApiError, - Application, - DiscordApiErrors, - FieldErrors, - Member, - Permissions, - User, - getPermission, -} from "@spacebar/util"; +import { ApiError, Application, DiscordApiErrors, FieldErrors, Member, Permissions, User, getPermission } from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { ApplicationAuthorizeSchema } from "@spacebar/schemas" +import { ApplicationAuthorizeSchema } from "@spacebar/schemas"; const router = Router({ mergeParams: true }); // TODO: scopes, other oauth types @@ -84,26 +75,18 @@ router.get( id: req.user_id, bot: false, }, - select: [ - "id", - "username", - "avatar", - "discriminator", - "public_flags", - ], + select: ["id", "username", "avatar", "discriminator", "public_flags"], }); const guilds = await Member.find({ where: { - user: { - id: req.user_id, - }, + id: req.user_id, }, relations: ["guild", "roles", "user"], // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore // prettier-ignore - select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id", "communication_disabled_until", "user.flags"], + select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id", "user.flags"], }); const guildsWithPermissions = guilds.map((x) => { @@ -112,7 +95,7 @@ router.get( id: user.id, roles: x.roles?.map((x) => x.id) || [], communication_disabled_until: x.communication_disabled_until, - flags: x.user.flags + flags: x.user.flags, }, guild: { roles: x?.roles || [], @@ -211,18 +194,9 @@ router.post( // TODO: captcha verification // TODO: MFA verification - const perms = await getPermission( - req.user_id, - body.guild_id, - undefined, - { member_relations: ["user"] }, - ); + const perms = await getPermission(req.user_id, body.guild_id, undefined, { member_relations: ["user"] }); // getPermission cache won't exist if we're owner - if ( - Object.keys(perms.cache || {}).length > 0 && - perms.cache.member?.user.bot - ) - throw DiscordApiErrors.UNAUTHORIZED; + if (Object.keys(perms.cache || {}).length > 0 && perms.cache.member?.user.bot) throw DiscordApiErrors.UNAUTHORIZED; perms.hasThrow("MANAGE_GUILD"); const app = await Application.findOne({ @@ -235,12 +209,7 @@ router.post( // TODO: use DiscordApiErrors // findOneOrFail throws code 404 if (!app) throw new ApiError("Unknown Application", 10002, 404); - if (!app.bot) - throw new ApiError( - "OAuth2 application does not have a bot", - 50010, - 400, - ); + if (!app.bot) throw new ApiError("OAuth2 application does not have a bot", 50010, 400); await Member.addToGuild(app.id, body.guild_id); diff --git a/src/api/routes/users/#user_id/delete.ts b/src/api/routes/users/#user_id/delete.ts
index dbb18e290..50494a238 100644 --- a/src/api/routes/users/#user_id/delete.ts +++ b/src/api/routes/users/#user_id/delete.ts
@@ -17,9 +17,25 @@ */ import { route } from "@spacebar/api"; -import { emitEvent, Member, User, UserDeleteEvent } from "@spacebar/util"; +import { + Channel, + ChannelDeleteEvent, + ChannelRecipientRemoveEvent, + emitEvent, + Emoji, + Guild, + InstanceBan, + Member, + Recipient, + Sticker, + Stopwatch, + User, + UserDeleteEvent, + UserSettingsProtos, +} from "@spacebar/util"; import { Request, Response, Router } from "express"; -import { PrivateUserProjection } from "@spacebar/schemas"; +import { ChannelType, InstanceUserDeleteSchema, PrivateUserProjection } from "@spacebar/schemas"; +import { Not } from "typeorm"; const router = Router({ mergeParams: true }); @@ -27,6 +43,7 @@ router.post( "/", route({ right: "MANAGE_USERS", + requestBody: "InstanceUserDeleteSchema", responses: { 204: {}, 403: { @@ -38,12 +55,132 @@ router.post( }, }), async (req: Request, res: Response) => { - await User.findOneOrFail({ + const sw = Stopwatch.startNew(); + const body = req.body as InstanceUserDeleteSchema | undefined; + const user = await User.findOneOrFail({ where: { id: req.params.user_id }, select: [...PrivateUserProjection, "data"], }); + + if (!(await InstanceBan.findOne({ where: { user_id: user.id } }))) + await InstanceBan.create({ user_id: user.id, reason: body?.reason ?? "<legacy instance ban API - no reason specified>" }).save(); + + // prevent bugginess with clients - delete all DMs, only having half of the conversation is quite useless anyhow + const dmChannels = await user.getDmChannels(); + for (const channel of dmChannels) { + console.log(`[Instance ban] Deleting DM channel ${channel.id} for user ${user.id}`); + await emitEvent({ + event: "CHANNEL_DELETE", + data: channel.toJSON(), + channel_id: channel.id, + } as ChannelDeleteEvent); + await Recipient.delete({ channel_id: channel.id }); + await Channel.deleteChannel(channel); + } + + //leave all group channels + const groupChannels = await Channel.find({ + where: { type: ChannelType.GROUP_DM }, + relations: ["recipients"], + select: { + id: true, + owner_id: true, + recipients: { + id: true, + user_id: true, + }, + }, + }); + + await Promise.all( + groupChannels.map(async (channel) => { + const recipient = channel.recipients!.find((r) => r.user_id === user.id); + if (recipient) { + await Recipient.delete({ id: recipient.id }); + await emitEvent({ + event: "CHANNEL_RECIPIENT_REMOVE", + data: { + user: user.toPublicUser(), + channel_id: channel.id, + }, + channel_id: channel.id, + } as ChannelRecipientRemoveEvent); + console.log(`[Instance ban] Removed user ${user.id} from group channel ${channel.id}`); + } + + // if no recipients remain, delete the channel + const remainingRecipients = await Recipient.find({ where: { channel_id: channel.id } }); + if (remainingRecipients.length === 0) { + await emitEvent({ + event: "CHANNEL_DELETE", + data: channel.toJSON(), + channel_id: channel.id, + } as ChannelDeleteEvent); + await Channel.deleteChannel(channel); + console.log(`[Instance ban] Deleted empty group channel ${channel.id}`); + } else { + // otherwise, if the banned user was the owner, reassign ownership + if (channel.owner_id === user.id) { + channel.owner_id = remainingRecipients[0].user_id; + await channel.save(); + console.log(`[Instance ban] Reassigned ownership of group channel ${channel.id} to user ${channel.owner_id}`); + } + } + }), + ); + + // change ownership on guilds + const guilds = await Guild.find({ where: { owner_id: req.params.user_id } }); + await Promise.all( + guilds.map(async (guild) => { + const members = await Member.find({ + where: { guild_id: guild.id, id: Not(req.params.user_id) }, + relations: { roles: true }, + select: { id: true, roles: { id: true, position: true } }, + }); + const sortedMembers = members + .filter((m) => m.id !== req.params.user_id) + .sort((a, b) => { + const aHighestRole = a.roles.reduce((prev, curr) => (curr.position > prev.position ? curr : prev), { position: -1 } as { position: number }); + const bHighestRole = b.roles.reduce((prev, curr) => (curr.position > prev.position ? curr : prev), { position: -1 } as { position: number }); + return bHighestRole.position - aHighestRole.position; + }); + if (sortedMembers.length === 0) { + // no members left, delete guild + await guild.remove(); + console.log(`[Instance ban] Deleted guild ${guild.id} as user ${user.id} was the last member`); + } else { + // assign new owner + guild.owner_id = sortedMembers[0].id; + await guild.save(); + console.log(`[Instance ban] Transferred ownership of guild ${guild.id} to user ${guild.owner_id}`); + + // safety - reassign emojis/stickers owned by the old owner + const stickers = await Sticker.find({ where: { guild_id: guild.id, user_id: req.params.user_id } }); + await Promise.all( + stickers.map(async (sticker) => { + sticker.user_id = guild.owner_id; + await sticker.save(); + console.log(`[Instance ban] Reassigned sticker ${sticker.id} ownership to user ${guild.owner_id}`); + }), + ); + + const emojis = await Emoji.find({ where: { guild_id: guild.id, user_id: req.params.user_id } }); + await Promise.all( + emojis.map(async (emoji) => { + emoji.user_id = guild.owner_id!; + await emoji.save(); + console.log(`[Instance ban] Reassigned emoji ${emoji.id} ownership to user ${guild.owner_id}`); + }), + ); + } + }), + ); + const members = await Member.find({ where: { id: req.params.user_id } }); - await Promise.all([...members.map((member) => Member.removeFromGuild(member.id, member.guild_id)), User.delete({ id: req.params.user_id })]); + await Promise.all([...members.map((member) => Member.removeFromGuild(member.id, member.guild_id))]); + await UserSettingsProtos.delete({ user_id: req.params.user_id }); + await User.delete({ id: req.params.user_id }); // TODO: respect intents as USER_DELETE has potential to cause privacy issues await emitEvent({ @@ -52,6 +189,7 @@ router.post( data: { user_id: req.params.user_id }, } as UserDeleteEvent); + console.log(`[Instance ban] Deleted user ${user.id} from instance in ${sw.elapsed().toString()}`); res.sendStatus(204); }, ); diff --git a/src/api/routes/voice/regions.ts b/src/api/routes/voice/regions.ts
index 9fdd6fdef..ba2612316 100644 --- a/src/api/routes/voice/regions.ts +++ b/src/api/routes/voice/regions.ts
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { getIpAdress, getVoiceRegions, route } from "@spacebar/api"; +import { getVoiceRegions, route } from "@spacebar/api"; import { Request, Response, Router } from "express"; const router: Router = Router({ mergeParams: true }); @@ -31,7 +31,7 @@ router.get( }, }), async (req: Request, res: Response) => { - res.json(await getVoiceRegions(getIpAdress(req), true)); //vip true? + res.json(await getVoiceRegions(req.ip!, true)); //vip true? }, ); diff --git a/src/api/util/utility/ipAddress.ts b/src/api/util/utility/ipAddress.ts
index 914999a71..19408253d 100644 --- a/src/api/util/utility/ipAddress.ts +++ b/src/api/util/utility/ipAddress.ts
@@ -16,41 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { Config } from "@spacebar/util"; -import { Request } from "express"; - -export function getIpAdress(req: Request): string { - // TODO: express can do this (trustProxies: true)? - - return req.ip!; -} - type Location = { latitude: number; longitude: number }; -export function distanceBetweenLocations( - loc1: Location, - loc2: Location, -): number { - return distanceBetweenCoords( - loc1.latitude, - loc1.longitude, - loc2.latitude, - loc2.longitude, - ); +export function distanceBetweenLocations(loc1: Location, loc2: Location): number { + return distanceBetweenCoords(loc1.latitude, loc1.longitude, loc2.latitude, loc2.longitude); } //Haversine function -function distanceBetweenCoords( - lat1: number, - lon1: number, - lat2: number, - lon2: number, -) { +function distanceBetweenCoords(lat1: number, lon1: number, lat2: number, lon2: number) { const p = 0.017453292519943295; // Math.PI / 180 const c = Math.cos; - const a = - 0.5 - - c((lat2 - lat1) * p) / 2 + - (c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p))) / 2; + const a = 0.5 - c((lat2 - lat1) * p) / 2 + (c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p))) / 2; return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km }