diff --git a/.env.demo.local b/.env.demo.local index 1a40c6e..aed241a 100644 --- a/.env.demo.local +++ b/.env.demo.local @@ -33,3 +33,4 @@ REVIEW_LOG_WEBHOOK_URL= OPEN_REVIEW_LOG_WEBHOOK_URL= STATS_LOG_WEBHOOK_URL= REPORT_WEBHOOK_URL= +NOTICE_LOG_WEBHOOK_URL= diff --git a/components/BotCard.tsx b/components/BotCard.tsx index b864ac8..b0fb38d 100644 --- a/components/BotCard.tsx +++ b/components/BotCard.tsx @@ -18,7 +18,8 @@ const BotCard: React.FC = ({ manage = false, bot }) => {
= ({ type, server }) => {
el) .catch((e) => { ResponseWrapper(res, { code: 400, errors: e.errors }) @@ -219,11 +223,52 @@ const Bots = RequestHandler() }) if (!validated) return + if ( + !checkBotFlag(bot.flags, 'trusted') && + !checkBotFlag(bot.flags, 'partnered') && + (validated.vanity || validated.banner || validated.bg) + ) + return ResponseWrapper(res, { + code: 403, + message: '해당 봇은 특전을 이용할 권한이 없습니다.', + }) + if (validated.vanity) { + const vanity = await get.bot.load(validated.vanity) + if (vanity && vanity.id !== bot.id) { + return ResponseWrapper(res, { + code: 403, + message: '이미 사용중인 한디리 커스텀 URL 입니다.', + errors: ['다른 커스텀 URL로 다시 시도해주세요.'], + }) + } + await webhookClients.internal.noticeLog.send({ + embeds: [ + { + title: '한디리 커스텀 URL 변경', + description: `봇: ${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot( + bot.id + )}))`, + fields: [ + { + name: '이전', + value: bot.vanity || '없음', + }, + { + name: '이후', + value: validated.vanity || '없음', + }, + ], + color: Colors.Blue, + } + ], + }) + } const result = await update.bot(req.query.id, validated) if (result === 0) return ResponseWrapper(res, { code: 400 }) else { get.bot.clear(req.query.id) + get.bot.clear(bot.vanity) const embed = new EmbedBuilder().setDescription( `${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))` ) @@ -237,6 +282,9 @@ const Bots = RequestHandler() discord: bot.discord, intro: bot.intro, category: JSON.stringify(bot.category), + vanity: bot.vanity, + banner: bot.banner, + bg: bot.bg, }, { prefix: validated.prefix, @@ -247,6 +295,9 @@ const Bots = RequestHandler() discord: validated.discord, intro: validated.intro, category: JSON.stringify(validated.category), + vanity: validated.vanity, + banner: validated.banner, + bg: validated.bg, } ) diffData.forEach((d) => { diff --git a/pages/bots/[id]/edit.tsx b/pages/bots/[id]/edit.tsx index b273d3d..913aee6 100644 --- a/pages/bots/[id]/edit.tsx +++ b/pages/bots/[id]/edit.tsx @@ -9,7 +9,7 @@ import { ParsedUrlQuery } from 'querystring' import { getJosaPicker } from 'josa' import { get } from '@utils/Query' -import { checkUserFlag, cleanObject, makeBotURL, parseCookie, redirectTo } from '@utils/Tools' +import { checkBotFlag, checkUserFlag, cleanObject, makeBotURL, parseCookie, redirectTo } from '@utils/Tools' import { ManageBot, ManageBotSchema } from '@utils/Yup' import { botCategories, botCategoryDescription, library } from '@utils/Constants' import { Bot, Theme, User } from '@types' @@ -58,7 +58,7 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } else return null } - if (!bot) return + if (!bot?.id) return if (!user) return ( @@ -70,6 +70,7 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } !checkUserFlag(user.flags, 'staff') ) return + const isPerkAvailable = checkBotFlag(bot.flags, 'trusted') || checkBotFlag(bot.flags, 'partnered') return ( @@ -87,6 +88,9 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } url: bot.url, git: bot.git, discord: bot.discord, + vanity: isPerkAvailable && bot.vanity, + banner: isPerkAvailable && bot.banner, + bg: isPerkAvailable && bot.bg, _csrf: csrfToken, })} validationSchema={ManageBotSchema} @@ -269,6 +273,44 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } + { + isPerkAvailable && ( + <> + +

신뢰된 봇 특전 설정

+ 신뢰된 봇의 혜택을 만나보세요. (커스텀 URL과 배너/배경 이미지는 이용약관 및 가이드라인을 준수해야하며 위반 시 신뢰된 봇 자격이 박탈될 수 있습니다.) + + + + + ) + }

* = 필수 항목 @@ -278,6 +320,7 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } 저장 + )} @@ -574,9 +617,11 @@ const ManageBotPage: NextPage = ({ bot, user, csrfToken, theme } export const getServerSideProps = async (ctx: Context) => { const parsed = parseCookie(ctx.req) const user = await get.Authorization(parsed?.token) + const bot = await get.bot.load(ctx.query.id) + const spec = await get.botSpec(bot?.id || '', user || '') return { props: { - bot: await get.bot.load(ctx.query.id), + bot: { ...bot, banner: spec?.banner || null, bg: spec?.bg || null }, user: await get.user.load(user || ''), csrfToken: getToken(ctx.req, ctx.res), }, diff --git a/pages/bots/koreanbots.tsx b/pages/bots/koreanbots.tsx deleted file mode 100644 index e7070be..0000000 --- a/pages/bots/koreanbots.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NextPage } from 'next' -import { useRouter } from 'next/router' - -const Reserved: NextPage = () => { - const router = useRouter() - router.push('/bots/iu') - return <> -} - -export default Reserved diff --git a/pages/servers/[id]/index.tsx b/pages/servers/[id]/index.tsx index cfbcbae..8d26e39 100644 --- a/pages/servers/[id]/index.tsx +++ b/pages/servers/[id]/index.tsx @@ -42,7 +42,8 @@ const Servers: NextPage = ({ data, desc, date, user, theme }) => { const [emojisModal, setEmojisModal] = useState(false) const [ownersModal, setOwnersModal] = useState(false) const [owners, setOwners] = useState(null) - const bg = checkBotFlag(data?.flags, 'trusted') && data?.banner + const bg = + (checkBotFlag(data?.flags, 'trusted') || checkBotFlag(data?.flags, 'partnered')) && data?.bg const router = useRouter() useEffect(() => { if (data) @@ -143,7 +144,12 @@ const Servers: NextPage = ({ data, desc, date, user, theme }) => {

- +

diff --git a/types/index.ts b/types/index.ts index 6a92f7a..a8d7295 100644 --- a/types/index.ts +++ b/types/index.ts @@ -89,6 +89,8 @@ export interface BotSpec { webhookURL: string | null webhookStatus: WebhookStatus token: string + banner: string | null + bg: string | null } export interface ServerSpec { @@ -351,7 +353,7 @@ export interface ImageOptions { export interface KoreanbotsImageOptions { format?: 'webp' | 'png' | 'gif' size?: 128 | 256 | 512 - hash?: string; + hash?: string } export enum DiscordImageType { diff --git a/utils/Constants.ts b/utils/Constants.ts index 7a15623..2460d14 100644 --- a/utils/Constants.ts +++ b/utils/Constants.ts @@ -626,3 +626,10 @@ export const GuildPermissions = { }, ], } + +export const reservedVanityBypass = [ + '653534001742741552', + '784618064167698472', + '653083797763522580', + '807561475014262785', +] diff --git a/utils/DiscordBot.ts b/utils/DiscordBot.ts index 928041f..ad3252a 100644 --- a/utils/DiscordBot.ts +++ b/utils/DiscordBot.ts @@ -57,6 +57,10 @@ export const webhookClients = { { url: process.env.REPORT_WEBHOOK_URL ?? dummyURL }, { allowedMentions: { parse: [] } } ), + noticeLog: new Discord.WebhookClient( + { url: process.env.NOTICE_LOG_WEBHOOK_URL ?? dummyURL }, + { allowedMentions: { parse: [] } } + ), }, } diff --git a/utils/Query.ts b/utils/Query.ts index 93c2193..6a4eb4f 100644 --- a/utils/Query.ts +++ b/utils/Query.ts @@ -100,6 +100,8 @@ async function getBot(id: string, topLevel = true): Promise { res.name = name res.category = JSON.parse(res.category) res.owners = JSON.parse(res.owners) + res.banner = res.banner ? camoUrl(res.banner) : null + res.bg = res.bg ? camoUrl(res.bg) : null if (discordBot.flags.bitfield & UserFlags.BotHTTPInteractions) { res.status = 'online' @@ -676,7 +678,14 @@ async function submitServer( async function getBotSpec(id: string, userID: string): Promise { const res = await knex('bots') - .select(['bots.id', 'bots.token', 'bots.webhook_url', 'bots.webhook_status']) + .select([ + 'bots.id', + 'bots.token', + 'bots.webhook_url', + 'bots.webhook_status', + 'bots.banner', + 'bots.bg', + ]) .leftJoin('owners_mapping', 'bots.id', 'owners_mapping.target_id') .where('owners_mapping.user_id', userID) .andWhere('owners_mapping.type', ObjectType.Bot) @@ -688,6 +697,8 @@ async function getBotSpec(id: string, userID: string): Promise { token: res[0].token, webhookURL: res[0].webhook_url, webhookStatus: res[0].webhook_status, + banner: res[0].banner, + bg: res[0].bg, } } @@ -733,6 +744,9 @@ async function updateBot(id: string, data: ManageBot): Promise { category: JSON.stringify(data.category), intro: data.intro, desc: data.desc, + vanity: data.vanity, + banner: data.banner, + bg: data.bg, }) .where({ id }) diff --git a/utils/Regex.ts b/utils/Regex.ts index cd6882a..5888546 100644 --- a/utils/Regex.ts +++ b/utils/Regex.ts @@ -1,4 +1,12 @@ import urlRegex from 'url-regex-safe' +const reservedVanityConst = [ + 'koreanbots', + 'koreanservers', + 'koreanlist', + 'kbots', + 'kodl', + 'discord', +] export const ID = /^[0-9]{17,}$/ export const Vanity = /^[A-Za-z\d-]+$/ @@ -12,3 +20,4 @@ export const Heading = '(.*?)<\\/h(\\d)>' export const EmojiSyntax = ':(\\w+):' export const ImageTag = /]*?alt\s*=\s*['"]([^'"]*?)['"][^>]*?>/ export const markdownImage = /!\[([^\]]*)\]\((.*?)\s*("(?:.*[^"])")?\s*\)/g +export const reservedVanity = new RegExp(`^((?!${reservedVanityConst.join('|')}).)*$`, 'i') // 예약되지 않음을 확인 diff --git a/utils/Yup.ts b/utils/Yup.ts index d070403..37c41ef 100644 --- a/utils/Yup.ts +++ b/utils/Yup.ts @@ -1,8 +1,14 @@ import * as Yup from 'yup' import YupKorean from 'yup-locales-ko' import { ListType } from '@types' -import { botCategories, library, reportCats, serverCategories } from '@utils/Constants' -import { HTTPProtocol, ID, Prefix, Url, Vanity } from '@utils/Regex' +import { + botCategories, + library, + reportCats, + reservedVanityBypass, + serverCategories, +} from '@utils/Constants' +import { HTTPProtocol, ID, Prefix, reservedVanity, Url, Vanity } from '@utils/Regex' Yup.setLocale(YupKorean) Yup.addMethod(Yup.array, 'unique', function (message, mapper = (a) => a) { @@ -296,6 +302,29 @@ export const ManageBotSchema: Yup.SchemaOf = Yup.object({ .min(100, '봇 설명은 최소 100자여야합니다.') .max(1500, '봇 설명은 최대 1500자여야합니다.') .required('봇 설명은 필수 항목입니다.'), + vanity: Yup.string() + .matches(Vanity, '커스텀 URL은 영문만 포함할 수 있습니다.') + .when('id', { + is: (id: string) => reservedVanityBypass.includes(id), + then: Yup.string(), + otherwise: Yup.string().matches( + reservedVanity, + '예약어가 포함되었거나 사용할 수 없는 커스텀 URL입니다.' + ), + }) + .min(2, '커스텀 URL은 최소 2자여야합니다.') + .max(32, '커스텀 URL은 최대 32자여야합니다.') + .nullable(), + banner: Yup.string() + .matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.') + .matches(Url, '올바른 배너 URL을 입력해주세요.') + .max(612, 'URL은 최대 612자까지만 가능합니다.') + .nullable(), + bg: Yup.string() + .matches(HTTPProtocol, 'http:// 또는 https:// 로 시작해야합니다.') + .matches(Url, '올바른 배경 URL을 입력해주세요.') + .max(612, 'URL은 최대 612자까지만 가능합니다.') + .nullable(), _csrf: Yup.string().required(), }) @@ -309,6 +338,9 @@ export interface ManageBot { category: string[] intro: string desc: string + vanity: string + banner: string + bg: string _csrf: string }