diff --git a/next.config.js b/next.config.js index b1267ff..c09d0b8 100644 --- a/next.config.js +++ b/next.config.js @@ -4,6 +4,12 @@ const withPWA = require('next-pwa') const VERSION = require('./package.json').version const NextConfig = { + webpack: (config, { isServer }) => { + if (!isServer) { + config.resolve.fallback.fs = false + } + return config + }, pwa: { disable: process.env.NODE_ENV !== 'production', register: false diff --git a/package.json b/package.json index fee810c..2e98817 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "csrf": "3.1.0", "dataloader": "2.0.0", "dayjs": "1.10.4", + "difflib": "0.2.4", "discord.js": "12.5.3", "emoji-mart": "3.0.1", "erlpack": "0.1.3", diff --git a/pages/api/v2/bots/[id]/index.ts b/pages/api/v2/bots/[id]/index.ts index 3b4c4ce..052c8f4 100644 --- a/pages/api/v2/bots/[id]/index.ts +++ b/pages/api/v2/bots/[id]/index.ts @@ -1,5 +1,6 @@ import { NextApiRequest } from 'next' import rateLimit from 'express-rate-limit' +import { MessageEmbed } from 'discord.js' import { CaptchaVerify, get, put, remove, update } from '@utils/Query' import ResponseWrapper from '@utils/ResponseWrapper' @@ -7,7 +8,9 @@ import { checkToken } from '@utils/Csrf' import { AddBotSubmit, AddBotSubmitSchema, CsrfCaptcha, ManageBot, ManageBotSchema } from '@utils/Yup' import RequestHandler from '@utils/RequestHandler' import { User } from '@types' -import { checkUserFlag } from '@utils/Tools' +import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools' +import { discordLog, getBotReviewLogChannel } from '@utils/DiscordBot' +import { KoreanbotsEndPoints } from '@utils/Constants' const patchLimiter = rateLimit({ windowMs: 2 * 60 * 1000, @@ -73,6 +76,12 @@ const Bots = RequestHandler() errors: ['봇 신청하시기 위해서는 공식 디스코드 서버에 참가해주셔야합니다.'], }) get.botSubmits.clear(user) + + await discordLog('BOT/SUBMIT', user, new MessageEmbed().setDescription(`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(result.id, result.date)})`), { + content: inspect(serialize(result)), + format: 'js' + }) + await getBotReviewLogChannel().send(new MessageEmbed().setTitle('심사 대기 중').setColor('GREY').setDescription(`[${result.id}/${result.date}](${KoreanbotsEndPoints.URL.submittedBot(result.id, result.date)})`).setFooter(Date.now())) return ResponseWrapper(res, { code: 200, data: result }) }) .delete(async (req: DeleteApiRequest, res) => { @@ -90,8 +99,13 @@ const Bots = RequestHandler() if(req.body.name !== bot.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' }) remove.bot(bot.id) get.user.clear(user) + await discordLog('BOT/DELETE', user, (new MessageEmbed().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)), + { + content: inspect(bot), + format: 'js' + } + ) return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' }) - }) .patch(patchLimiter).patch(async (req: PatchApiRequest, res) => { const bot = await get.bot.load(req.query.id) @@ -112,11 +126,24 @@ const Bots = RequestHandler() }) if (!validated) return - console.log(validated) const result = await update.bot(req.query.id, validated) if(result === 0) return ResponseWrapper(res, { code: 400 }) else { get.bot.clear(req.query.id) + const embed = new MessageEmbed().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`) + const diffData = objectDiff( + { prefix: bot.prefix, library: bot.lib, web: bot.web, git: bot.git, url: bot.url, discord: bot.discord, intro: bot.intro, category: JSON.stringify(bot.category) }, + { prefix: validated.prefix, library: validated.library, web: validated.website, git: validated.git, url: validated.url, discord: validated.discord, intro: validated.intro, category: JSON.stringify(validated.category) } + ) + diffData.map(d => { + embed.addField(d[0], makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff')) + }) + await discordLog('BOT/EDIT', user, embed, + { + content: `--- 설명\n${diff(bot.desc, validated.desc, true)}`, + format: 'diff' + } + ) return ResponseWrapper(res, { code: 200 }) } diff --git a/pages/api/v2/bots/[id]/owners.ts b/pages/api/v2/bots/[id]/owners.ts index da4f340..f517603 100644 --- a/pages/api/v2/bots/[id]/owners.ts +++ b/pages/api/v2/bots/[id]/owners.ts @@ -4,9 +4,12 @@ import RequestHandler from '@utils/RequestHandler' import { CaptchaVerify, get, update } from '@utils/Query' import ResponseWrapper from '@utils/ResponseWrapper' import { checkToken } from '@utils/Csrf' -import { checkUserFlag } from '@utils/Tools' +import { checkUserFlag, diff, makeDiscordCodeblock } from '@utils/Tools' import { EditBotOwner, EditBotOwnerSchema } from '@utils/Yup' import { User } from '@types' +import { discordLog } from '@utils/DiscordBot' +import { MessageEmbed } from 'discord.js' +import { KoreanbotsEndPoints } from '@utils/Constants' const BotOwners = RequestHandler() .patch(async (req: PostApiRequest, res) => { @@ -33,6 +36,7 @@ const BotOwners = RequestHandler() if(userFetched.length > 1 && userFetched[0].id !== (bot.owners as User[])[0].id) return ResponseWrapper(res, { code: 400, errors: ['소유자를 이전할 때는 다른 관리자를 포함할 수 없습니다.'] }) await update.botOwners(bot.id, validated.owners) get.user.clear(user) + await discordLog('BOT/OWNERS', userinfo.id, (new MessageEmbed().setDescription(`${bot.name} - <@${bot.id}> ([${bot.id}](${KoreanbotsEndPoints.URL.bot(bot.id)}))`)), null, makeDiscordCodeblock(diff(JSON.stringify(bot.owners.map(el => el.id)), JSON.stringify(validated.owners)), 'diff')) return ResponseWrapper(res, { code: 200 }) }) diff --git a/pages/api/v2/bots/[id]/report.ts b/pages/api/v2/bots/[id]/report.ts index 247b2a1..74c7fe9 100644 --- a/pages/api/v2/bots/[id]/report.ts +++ b/pages/api/v2/bots/[id]/report.ts @@ -12,6 +12,7 @@ const limiter = rateLimit({ windowMs: 5 * 60 * 1000, max: 3, statusCode: 429, + skipFailedRequests: true, handler: (_req, res) => ResponseWrapper(res, { code: 429 }), keyGenerator: (req) => req.headers['x-forwarded-for'] as string, skip: (_req, res) => { diff --git a/pages/api/v2/bots/[id]/stats.ts b/pages/api/v2/bots/[id]/stats.ts index 2aaf9dd..40601ea 100644 --- a/pages/api/v2/bots/[id]/stats.ts +++ b/pages/api/v2/bots/[id]/stats.ts @@ -1,15 +1,20 @@ import { NextApiRequest } from 'next' import rateLimit from 'express-rate-limit' +import { MessageEmbed } from 'discord.js' import { get, update } from '@utils/Query' import RequestHandler from '@utils/RequestHandler' import ResponseWrapper from '@utils/ResponseWrapper' import { BotStatUpdate, BotStatUpdateSchema } from '@utils/Yup' +import { discordLog } from '@utils/DiscordBot' +import { makeDiscordCodeblock } from '@utils/Tools' +import { KoreanbotsEndPoints } from '@utils/Constants' const limiter = rateLimit({ windowMs: 3 * 60 * 1000, max: 3, statusCode: 429, + skipFailedRequests: true, handler: (_req, res) => ResponseWrapper(res, { code: 429 }), keyGenerator: (req) => req.headers.authorization, skip: (req, res) => { @@ -37,6 +42,8 @@ const BotStats = RequestHandler().post(limiter) if(botInfo.id !== bot) return ResponseWrapper(res, { code: 403 }) const d = await update.updateServer(botInfo.id, validated.servers) if(d===1 || d===2) return ResponseWrapper(res, { code: 403, message: `서버 수를 ${[null, '1만', '100만'][d]} 이상으로 설정하실 수 없습니다. 문의해주세요.` }) + get.bot.clear(req.query.id) + await discordLog('BOT/STATS', botInfo.id, (new MessageEmbed().setDescription(`${botInfo.name} - <@${botInfo.id}> ([${botInfo.id}](${KoreanbotsEndPoints.URL.bot(botInfo.id)}))`)), null, makeDiscordCodeblock(`${botInfo.servers > validated.servers ? '-' : '+'} ${botInfo.servers} -> ${validated.servers} (${botInfo.servers > validated.servers ? '▼' : '▲'}${Math.abs(validated.servers - botInfo.servers)})`, 'diff')) return ResponseWrapper(res, { code: 200, message: '성공적으로 업데이트 했습니다.'}) }) diff --git a/types/global.d.ts b/types/global.d.ts index b300341..2dbb135 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -17,3 +17,8 @@ declare module 'yup' { unique(format?: string): this } } + + +declare module 'difflib' { + export function unifiedDiff(before: string, after: string): string[] +} \ No newline at end of file diff --git a/utils/Constants.ts b/utils/Constants.ts index 371d6a1..d902658 100644 --- a/utils/Constants.ts +++ b/utils/Constants.ts @@ -133,7 +133,7 @@ export const reportCats = [ export const imageSafeHost = [ 'koreanbots.dev', 'githubusercontent.com', - 'cdn.discordapp.com' + 'cdn.discordapp.com' ] export const MessageColor = { @@ -188,8 +188,14 @@ export const DiscordEnpoints = { } export const KoreanbotsEndPoints = { - CDN: class CDN { - static avatar (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`/api/image/discord/avatars/${id}`, options) } + CDN: class { + static root = '/api/image' + static avatar (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`${this.root}/discord/avatars/${id}`, options) } + }, + URL: class { + static root = process.env.KOREANBOTS_URL || 'https://koreanbots.dev' + static bot (id: string) { return `${this.root}/bots/${id}` } + static submittedBot(id: string, date: number) { return `${this.root}/pendingBots/${id}/${date}` } }, baseAPI: '/api/v2', login: '/api/auth/discord', diff --git a/utils/DiscordBot.ts b/utils/DiscordBot.ts index 9db4b66..0257af0 100644 --- a/utils/DiscordBot.ts +++ b/utils/DiscordBot.ts @@ -1,9 +1,12 @@ import * as Discord from 'discord.js' -const DiscordBot = new Discord.Client() +export const DiscordBot = new Discord.Client() const guildID = '653083797763522580' + const reportChannelID = '813255797823766568' +const loggingChannelID = '844006379823955978' +const botReviewLogChannelID = '844044961975500840' DiscordBot.on('ready', async () => { console.log('I\'m Ready') @@ -13,6 +16,17 @@ DiscordBot.on('ready', async () => { DiscordBot.login(process.env.DISCORD_TOKEN) -const getMainGuild = () => DiscordBot.guilds.cache.get(guildID) -const getReportChannel = (): Discord.TextChannel => DiscordBot.channels.cache.get(reportChannelID) as Discord.TextChannel -export { DiscordBot, getMainGuild, getReportChannel } +export const getMainGuild = () => DiscordBot.guilds.cache.get(guildID) +export const getReportChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(reportChannelID) as Discord.TextChannel +export const getLoggingChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(loggingChannelID) as Discord.TextChannel +export const getBotReviewLogChannel = (): Discord.TextChannel => getMainGuild().channels.cache.get(botReviewLogChannelID) as Discord.TextChannel +export const discordLog = async (type: string, issuerID: string, embed?: Discord.MessageEmbed, attachment?: { content: string, format: string}, content?: string): Promise => { + getLoggingChannel().send({ + content: `[${type}] <@${issuerID}> (${issuerID})\n${content || ''}`, + embed: embed && embed.setTitle(type).setTimestamp(new Date()), + ...(attachment && { files: [ + attachment && new Discord.MessageAttachment(Buffer.from(attachment.content), `${type.toLowerCase().replace(/\//g, '-')}-${issuerID}-${Date.now()}.${attachment.format}`) + ] + }) + }) +} \ No newline at end of file diff --git a/utils/Query.ts b/utils/Query.ts index 88f9851..52b21e6 100644 --- a/utils/Query.ts +++ b/utils/Query.ts @@ -215,7 +215,7 @@ async function voteBot(userID: string, botID: string): Promise { * @returns 4 - Discord not Joined * @returns obj - Success */ -async function submitBot(id: string, data: AddBotSubmit):Promise { +async function submitBot(id: string, data: AddBotSubmit):Promise<1|2|3|4|SubmittedBot> { const submits = await knex('submitted').select(['id']).where({ state: 0 }).andWhere('owners', 'LIKE', `%${id}%`) if(submits.length > 1) return 1 const botId = data.id diff --git a/utils/RequestHandler.ts b/utils/RequestHandler.ts index 102cf62..55bb5f7 100644 --- a/utils/RequestHandler.ts +++ b/utils/RequestHandler.ts @@ -1,4 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next' +import * as Sentry from '@sentry/nextjs' import nc from 'next-connect' import rateLimit from 'express-rate-limit' @@ -22,6 +23,11 @@ const RequestHandler = () => onNoMatch(_req, res) { return ResponseWrapper(res, { code: 405 }) }, + onError(err, _req, res) { + console.error(err) + Sentry.captureException(err) + return ResponseWrapper(res, { code: 500 }) + } }) .use(limiter) diff --git a/utils/Tools.ts b/utils/Tools.ts index ea44711..5f8e52a 100644 --- a/utils/Tools.ts +++ b/utils/Tools.ts @@ -1,11 +1,13 @@ +import { NextRouter } from 'next/router' +import { inspect as utilInspect } from 'util' import { createHmac } from 'crypto' import { Readable } from 'stream' import cookie from 'cookie' +import * as difflib from 'difflib' import { BotFlags, ImageOptions, UserFlags } from '@types' import Logger from '@utils/Logger' import { BASE_URLs, KoreanbotsEndPoints, Oauth } from '@utils/Constants' -import { NextRouter } from 'next/router' export function handlePWA(): boolean { let displayMode = 'browser' @@ -56,6 +58,30 @@ export function serialize(data: T): T { return JSON.parse(JSON.stringify(data)) } +export function diff(original: string, current: string, header=false, sep='\n', join?: string) { + return difflib.unifiedDiff(original.split(sep), current.split(sep)).slice(header ? 2 : 3).join(join ?? sep) +} + +export function objectDiff(original: Record, current: Record): [string, (string|null)[] ][] { + const obj: Record = {} + Object.entries(original).forEach(k => + obj[k[0]] = [ k[1] ] + ) + Object.entries(current).forEach(k => { + if(!obj[k[0]]) obj[k[0]] = [] + obj[k[0]][1] = k[1] + }) + return Object.entries(obj).filter(k => k[1][0] !== k[1][1]) +} + +export function makeDiscordCodeblock(content: string, lang?: string): string { + return `\`\`\`${lang || ''}\n${content.replace(/```/g, '\\`\\`\\`')}\n\`\`\`` +} + +export function inspect(object: unknown) { + return utilInspect(object, { depth: Infinity, maxArrayLength: Infinity, maxStringLength: Infinity}) +} + export function supportsWebP() { const elem = document.createElement('canvas') if (elem.getContext && elem.getContext('2d')) { diff --git a/yarn.lock b/yarn.lock index 48222b4..be0cdf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3663,6 +3663,13 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +difflib@0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/difflib/-/difflib-0.2.4.tgz#b5e30361a6db023176d562892db85940a718f47e" + integrity sha1-teMDYabbAjF21WKJLbhZQKcY9H4= + dependencies: + heap ">= 0.2.0" + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -4812,6 +4819,11 @@ he@1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +"heap@>= 0.2.0": + version "0.2.6" + resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.6.tgz#087e1f10b046932fc8594dd9e6d378afc9d1e5ac" + integrity sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw= + hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"