diff --git a/bun.lockb b/bun.lockb index 471b656..0500b3d 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config.json.example b/config.json.example index d4deeac..4ab4641 100644 --- a/config.json.example +++ b/config.json.example @@ -31,6 +31,12 @@ "max": 5, "seconds": 60 }, + { + "method": "POST", + "regex": "\/players\/\\w+\/admin/?", + "max": 2, + "seconds": 30 + }, { "method": "POST", "regex": "\/players\/\\w+/position\/?", diff --git a/package.json b/package.json index 5086b23..9bc6119 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "globaltags", - "version": "1.2.3", + "version": "1.2.4", "module": "src/index.js", "scripts": { "dev": "bun --watch src/index.ts", @@ -14,12 +14,10 @@ "dependencies": { "@elysiajs/swagger": "^1.0.3", "axios": "^1.6.7", - "body-parser": "^1.20.2", "chalk": "^5.3.0", "cron": "^3.1.7", "discord.js": "^14.14.1", "elysia": "^1.0.10", - "express": "^4.18.2", "jsonwebtoken": "^9.0.1", "moment": "^2.29.4", "mongoose": "latest" diff --git a/src/bot/buttons/Actions.ts b/src/bot/buttons/Actions.ts index 49010aa..f8dbca2 100644 --- a/src/bot/buttons/Actions.ts +++ b/src/bot/buttons/Actions.ts @@ -40,16 +40,20 @@ export default class Actions extends Button { .setCustomId(`ban`) .setStyle(ButtonStyle.Danger), new ButtonBuilder() - .setLabel(`Clear tag`) - .setCustomId(`clearTag`) - .setStyle(ButtonStyle.Danger) + .setLabel(`Unban`) + .setCustomId(`unban`) + .setStyle(ButtonStyle.Success) ), new ActionRowBuilder() .addComponents( new ButtonBuilder() - .setLabel(`Unban`) - .setCustomId(`unban`) - .setStyle(ButtonStyle.Success) + .setLabel(`Clear tag`) + .setCustomId(`clearTag`) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setLabel(`Set tag`) + .setCustomId(`setTag`) + .setStyle(ButtonStyle.Primary) ) ] diff --git a/src/bot/buttons/SetTag.ts b/src/bot/buttons/SetTag.ts new file mode 100644 index 0000000..ae94bcc --- /dev/null +++ b/src/bot/buttons/SetTag.ts @@ -0,0 +1,37 @@ +import { ButtonInteraction, CacheType, Message, GuildMember, User, EmbedBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import Button from "../structs/Button"; +import players from "../../database/schemas/players"; +import { validation } from "../../../config.json"; +import { colors } from "../bot"; + +export default class SetTag extends Button { + constructor() { + super("setTag"); + } + + async trigger(interaction: ButtonInteraction, message: Message, member: GuildMember, user: User) { + const player = await players.findOne({ uuid: message.embeds[0].fields[0].value.replaceAll(`\``, ``) }); + if(!player) return interaction.reply({ embeds: [new EmbedBuilder().setColor(colors.error).setDescription(`❌ Player not found!`)], ephemeral: true }); + + const input = new TextInputBuilder() + .setLabel(`New tag`) + .setCustomId(`tag`) + .setPlaceholder(`Enter a tag`) + .setRequired(true) + .setMinLength(validation.tag.min) + .setMaxLength(validation.tag.max) + .setStyle(TextInputStyle.Short); + + if(player.tag) input.setValue(player.tag); + + const modal = new ModalBuilder() + .setTitle(`Set new tag`) + .setCustomId(`setTag`) + .addComponents( + new ActionRowBuilder() + .addComponents(input) + ) + + interaction.showModal(modal); + } +} \ No newline at end of file diff --git a/src/bot/modals/SetTag.ts b/src/bot/modals/SetTag.ts new file mode 100644 index 0000000..1472c94 --- /dev/null +++ b/src/bot/modals/SetTag.ts @@ -0,0 +1,20 @@ +import { CacheType, Message, GuildMember, User, EmbedBuilder, ModalSubmitInteraction, ModalSubmitFields } from "discord.js"; +import players from "../../database/schemas/players"; +import { colors } from "../bot"; +import Modal from "../structs/Modal"; + +export default class SetTag extends Modal { + constructor() { + super("setTag"); + } + + async submit(interaction: ModalSubmitInteraction, message: Message, fields: ModalSubmitFields, member: GuildMember, user: User) { + const player = await players.findOne({ uuid: message.embeds[0].fields[0].value.replaceAll(`\``, ``) }); + if(!player) return interaction.reply({ embeds: [new EmbedBuilder().setColor(colors.error).setDescription(`❌ Player not found!`)], ephemeral: true }); + + player.tag = fields.getTextInputValue('tag'); + player.save(); + + interaction.reply({ embeds: [new EmbedBuilder().setColor(colors.success).setDescription(`✅ The tag was successfully set!`)], ephemeral: true }); + } +} \ No newline at end of file diff --git a/src/libs/SessionValidator.ts b/src/libs/SessionValidator.ts index 2575c6b..0e6368f 100644 --- a/src/libs/SessionValidator.ts +++ b/src/libs/SessionValidator.ts @@ -1,10 +1,23 @@ import { verify } from "jsonwebtoken"; +import players from "../database/schemas/players"; const publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt3rCKqrQYcmSEE8zyQTA7flKIe1pr7GHY58lTF74Pw/ZZYzxmScYteXp8XBvrQfPj4U/v9Vum8IPg6GHOv1Gde3rY5ydfunEKi/w4ibVN5buPpndzcNaMoQvEJ/B5VLIzCvLc5HepFKbKFOGu8XoFz8NZY0lUfGLR0rcDsHWZLHPhqYsIsUd9snkWkHaIKD7l9xTd77PpLZiBwCPnVhh3invFY2OnCL6BfiJhhud/aDaAzFW981J9EhyACbuac2qu6Uz2bKX/7Af01gUs48MbKUx8YirBWLD7j/CJMWorTT467It4mAvDlw43s3Py9IvxCzEFnOIftIv+7wwv1RjVQIDAQAB\n-----END PUBLIC KEY-----"; -export function validJWTSession(token: string, uuid: string, equal: boolean): boolean { +type SessionData = { + uuid: string | null, + equal: boolean, + isAdmin: boolean +} + +export async function getJWTSession(token: string, uuid: string): Promise { const tokenUuid = getUuidByJWT(token); - if(equal) return tokenUuid === uuid; - else return !!tokenUuid; + if(!tokenUuid) return { uuid: tokenUuid, equal: tokenUuid == uuid, isAdmin: false }; + const data = await players.findOne({ uuid: tokenUuid }); + if(!data) return { uuid: tokenUuid, equal: tokenUuid == uuid, isAdmin: false }; + return { + uuid: tokenUuid, + equal: uuid == tokenUuid, + isAdmin: data.admin + } } type LabyPayload = { diff --git a/src/routes/ban.ts b/src/routes/ban.ts index 014c7dc..382e6e1 100644 --- a/src/routes/ban.ts +++ b/src/routes/ban.ts @@ -1,5 +1,5 @@ import Elysia, { t } from "elysia"; -import { getUuidByJWT, validJWTSession } from "../libs/SessionValidator"; +import { getUuidByJWT, getJWTSession } from "../libs/SessionValidator"; import players from "../database/schemas/players"; import fetchI18n from "../middleware/FetchI18n"; @@ -8,13 +8,8 @@ export default new Elysia({ }).use(fetchI18n).get(`/`, async ({ error, params, headers, i18n }) => { // Get ban info const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, false); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); - - const executor = await players.findOne({ uuid: getUuidByJWT(authorization)! }); - if(!executor || !executor.admin) return error(403, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.playerNotFound`) }); @@ -24,16 +19,10 @@ export default new Elysia({ params: t.Object({ uuid: t.String() }), headers: t.Object({ authorization: t.String({ error: `error.notAllowed` }) }, { error: `error.notAllowed` }) }).post(`/`, async ({ error, params, headers, body, i18n }) => { // Ban player - console.log(body.reason); const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, false); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); - - const executor = await players.findOne({ uuid: getUuidByJWT(authorization)! }); - if(!executor || !executor.admin) return error(403, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.playerNotFound`) }); @@ -53,13 +42,8 @@ export default new Elysia({ }).delete(`/`, async ({ error, params, headers, i18n }) => { // Unban player const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, false); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); - - const executor = await players.findOne({ uuid: getUuidByJWT(authorization)! }); - if(!executor || !executor.admin) return error(403, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.playerNotFound`) }); diff --git a/src/routes/icon.ts b/src/routes/icon.ts index d0ef4ea..8eac919 100644 --- a/src/routes/icon.ts +++ b/src/routes/icon.ts @@ -1,5 +1,5 @@ import Elysia, { t } from "elysia"; -import { validJWTSession } from "../libs/SessionValidator"; +import { getJWTSession } from "../libs/SessionValidator"; import players from "../database/schemas/players"; import * as config from "../../config.json"; import fetchI18n from "../middleware/FetchI18n"; @@ -10,10 +10,8 @@ export default new Elysia({ const uuid = params.uuid.replaceAll(`-`, ``); const icon = body.icon.toUpperCase(); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, true); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.equal && !session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.noTag`) }); @@ -25,7 +23,7 @@ export default new Elysia({ player.icon = icon; await player.save(); - return { message: i18n(`icon.success`) }; + return { message: i18n(`icon.success.${session.equal ? 'self' : 'admin'}`) }; }, { detail: { tags: ['Settings'], diff --git a/src/routes/position.ts b/src/routes/position.ts index f9f4235..2690689 100644 --- a/src/routes/position.ts +++ b/src/routes/position.ts @@ -1,5 +1,5 @@ import Elysia, { t } from "elysia"; -import { validJWTSession } from "../libs/SessionValidator"; +import { getJWTSession } from "../libs/SessionValidator"; import players from "../database/schemas/players"; import fetchI18n from "../middleware/FetchI18n"; @@ -9,10 +9,8 @@ export default new Elysia({ const uuid = params.uuid.replaceAll(`-`, ``); const position = body.position.toUpperCase(); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, true); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.equal && !session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.noTag`) }); @@ -24,7 +22,7 @@ export default new Elysia({ player.position = position as "ABOVE" | "BELOW" | "RIGHT" | "LEFT"; await player.save(); - return { message: i18n(`position.success`) }; + return { message: i18n(`position.success.${session.equal ? 'self' : 'admin'}`) }; }, { detail: { tags: ['Settings'], diff --git a/src/routes/report.ts b/src/routes/report.ts index 442d8f8..9a87fb5 100644 --- a/src/routes/report.ts +++ b/src/routes/report.ts @@ -1,5 +1,5 @@ import Elysia, { t } from "elysia"; -import { getUuidByJWT, validJWTSession } from "../libs/SessionValidator"; +import { getUuidByJWT, getJWTSession } from "../libs/SessionValidator"; import players from "../database/schemas/players"; import { NotificationType, sendMessage } from "../libs/DiscordNotifier"; import fetchI18n from "../middleware/FetchI18n"; @@ -9,10 +9,8 @@ export default new Elysia({ }).use(fetchI18n).post(`/`, async ({ error, params, headers, body, i18n }) => { // Report player const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, false); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.uuid) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.playerNoTag`) }); diff --git a/src/routes/root.ts b/src/routes/root.ts index 5032ac2..8b972a6 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -2,7 +2,7 @@ import Elysia, { t } from "elysia"; import players from "../database/schemas/players"; import Logger from "../libs/Logger"; import { sendMessage, NotificationType } from "../libs/DiscordNotifier"; -import { validJWTSession } from "../libs/SessionValidator"; +import { getJWTSession } from "../libs/SessionValidator"; import * as config from "../../config.json"; import fetchI18n from "../middleware/FetchI18n"; @@ -12,21 +12,22 @@ export default new Elysia() .use(fetchI18n).get(`/`, async ({ error, params, headers, i18n }) => { // Get player info const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, false); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(config.requireSessionIds && !authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(config.requireSessionIds && !session.uuid) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.playerNoTag`) }); - if(player.isBanned()) return error(403, { error: i18n(`getInfo.banned`) }); return { uuid: player.uuid, - tag: player.tag || null, + tag: player.isBanned() ? null : player.tag || null, position: player.position, icon: player.icon, - admin: player.admin + admin: player.admin, + ban: session.equal || session.isAdmin ? { + active: !!player.ban?.active, + reason: player.ban?.reason || null + } : null }; }, { detail: { @@ -34,7 +35,7 @@ export default new Elysia() description: `Get another players' tag info` }, response: { - 200: t.Object({ uuid: t.String(), tag: t.Union([t.String(), t.Null()]), position: t.String(), icon: t.String(), admin: t.Boolean({ default: false }) }, { description: `You received the tag data.` }), + 200: t.Object({ uuid: t.String(), tag: t.Union([t.String(), t.Null()]), position: t.String(), icon: t.String(), admin: t.Boolean({ default: false }), ban: t.Union([t.Object({ active: t.Boolean(), reason: t.Union([t.String(), t.Null()]) }), t.Null()]) }, { description: `You received the tag data.` }), 401: t.Object({ error: t.String() }, { description: `You're not authenticated with LabyConnect.` }), 403: t.Object({ error: t.String() }, { description: `The player is banned.` }), 404: t.Object({ error: t.String() }, { description: `The player is not in the database.` }), @@ -45,17 +46,15 @@ export default new Elysia() headers: t.Object({ authorization: config.requireSessionIds ? t.String({ error: `error.notAllowed`, description: `Your LabyConnect JWT` }) : t.Optional(t.String({ description: `Your LabyConnect JWT` })) }, { error: `error.notAllowed` }), }).post(`/`, async ({ error, params, headers, body, i18n }) => { // Change tag const uuid = params.uuid.replaceAll(`-`, ``); - const tag = body.tag; + const tag = body.tag.trim(); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, true); - - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + const session = await getJWTSession(authorization, uuid); + if(!session.equal && !session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); - if(player && player.isBanned()) return error(403, { error: i18n(`error.banned`) }); + if(player && player.isBanned()) return error(403, { error: i18n(`error.${session.equal ? 'b' : 'playerB'}anned`) }); const { blacklist, watchlist } = config.validation.tag; - if(tag.trim() == '') return error(422, { error: i18n(`setTag.empty`) }); + if(tag == '') return error(422, { error: i18n(`setTag.empty`) }); const blacklistedWord = blacklist.find((word) => tag.replace(colorCodeRegex, ``).toLowerCase().includes(word)); if(blacklistedWord) return error(422, { error: i18n(`setTag.blacklisted`).replaceAll(``, blacklistedWord) }); const isWatched = (player && player.watchlist) || watchlist.some((word) => { @@ -84,7 +83,7 @@ export default new Elysia() } if(isWatched) sendMessage({ type: NotificationType.WatchlistTagUpdate, uuid, tag }); - return { message: i18n(`setTag.success`) }; + return { message: i18n(`setTag.success.${session.equal ? 'self' : 'admin'}`) }; }, { detail: { tags: ['Settings'], @@ -102,23 +101,51 @@ export default new Elysia() params: t.Object({ uuid: t.String({ description: `Your UUID` }) }), body: t.Object({ tag: t.String({ minLength: config.validation.tag.min, maxLength: config.validation.tag.max, error: `setTag.validation;;[["min", "${config.validation.tag.min}"], ["max", "${config.validation.tag.max}"]]` }) }, { error: `error.invalidBody`, additionalProperties: true }), headers: t.Object({ authorization: t.String({ error: `error.notAllowed`, description: `Your LabyConnect JWT` }) }, { error: `error.notAllowed` }) -}).delete(`/`, async ({ error, params, headers, i18n }) => { // Delete tag +}).post(`/admin`, async ({ error, params, headers, i18n }) => { // Toggle admin const uuid = params.uuid.replaceAll(`-`, ``); const { authorization } = headers; - const authenticated = authorization && validJWTSession(authorization, uuid, true); + const session = await getJWTSession(authorization, uuid); + if(!session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); + + const player = await players.findOne({ uuid }); + if(!player) return error(404, { error: i18n(`error.playerNotFound`) }); + + player.admin = !player.admin; + await player.save(); - if(authorization == `0`) return error(401, { error: i18n(`error.premiumAccount`) }); - if(!authenticated) return error(401, { error: i18n(`error.notAllowed`) }); + return { message: i18n(`toggleAdmin.${player.admin ? 'on' : 'off'}`) }; +}, { + detail: { + tags: ['Settings'], + description: `Change your global tag` + }, + response: { + 200: t.Object({ message: t.String() }, { description: `The player's admin status has changed.` }), + 400: t.Object({ error: t.String() }, { description: `You already have this tag.` }), + 401: t.Object({ error: t.String() }, { description: `You're not authenticated with LabyConnect.` }), + 403: t.Object({ error: t.String() }, { description: `You're not an admin.` }), + 404: t.Object({ error: t.String() }, { description: `The player was not found.` }), + 422: t.Object({ error: t.String() }, { description: `You're lacking the validation requirements.` }), + 429: t.Object({ error: t.String() }, { description: `You're ratelimited.` }), + 503: t.Object({ error: t.String() }, { description: `Database is not reachable.` }) + }, + params: t.Object({ uuid: t.String({ description: `The player's UUID` }) }), + headers: t.Object({ authorization: t.String({ error: `error.notAllowed`, description: `Your LabyConnect JWT` }) }, { error: `error.notAllowed` }) +}).delete(`/`, async ({ error, params, headers, i18n }) => { // Delete tag + const uuid = params.uuid.replaceAll(`-`, ``); + const { authorization } = headers; + const session = await getJWTSession(authorization, uuid); + if(!session.equal && !session.isAdmin) return error(403, { error: i18n(`error.notAllowed`) }); const player = await players.findOne({ uuid }); if(!player) return error(404, { error: i18n(`error.noTag`) }); - if(player.isBanned()) return error(403, { error: i18n(`error.banned`) }); + if(player.isBanned()) return error(403, { error: i18n(`error.${session.equal ? 'b' : 'playerB'}anned`) }); if(!player.tag) return error(404, { error: i18n(`error.noTag`) }); player.tag = null; await player.save(); - return { message: i18n(`resetTag.success`) }; + return { message: i18n(`resetTag.success.${session.equal ? 'self' : 'admin'}`) }; }, { detail: { tags: ['Settings'],