Feature/serverlist skeleton (#468)

* deps: added mongoose

* feat(*): added mongo and saving invited count

* chore(env): updated mongo configuration

* chore: updated next-env.d.ts

* chore(*): changed categories to botCategories

* chore(Image): maded image component

* feat(ServerCard): added ServerCard component

* feat(ServerIcon): added ServerIcon component

* feat(Tools): added server related functions

* feat(Mongo): added serverSchema

* chore(Hero): support serverlist

* feat(Owner): added crown

* feat(icons): added icons api

* feat(Yup): added AddServerSubmitSchema

* types: added server related types

* chore(BotCard): changed bot category link

* chore(Hero): changed category links

* feat(ServerCard): added unreachable state display

* feat(Yup): added ManageServerSchema

* feat(Query): added server related queries

* feat(Constants): added server related stuffs

* types: added updatedAt field for ServerData

* feat(pages/servers/*): added server pages

* feat(*): moved bot category rotue

* typo: fixed typo issue

* feat(pages/addserver/*): added add server page

* feat(api/servers): added server related api

* feat(pages/servers): added server edit page

* feat(pages/bots): changed bot list route

* feat(*): server categories

* feat(pages/users): added owned server list

* chore(pages/bots): changed image size

* feat(docker-compose): added bot

* ci: made some changes

* types: fixed type

* types(Search): fixed type

* types(*): fixed type

* fix(*): missing fields

* fix: Hero type typo issue

* ci(*): missing sentry org slug

* ci(*): fix

* feat(*): added and changed search pages

* Update pages/addserver/[id].tsx

Co-authored-by: Ryu JuHeon <saidbysolo@gmail.com>

* feat(api/search): added servers search api

* feat(pages/panel): added server list in manage page

* feat(Search): supporting server search at SearchBox

* feat(pages/apllications/servers): added server application page

* chore(docker-compose): changed image link

* chore(utils): removing server cache at submit

* chore(image/icons): added debug code

* chore(*): changed component names

* chore(Query): decreased server cache ttl

* fix(Query): error on addserver page

close: https://github.com/koreanbots/serverlist-testing/issues/10

* fix(Query): not using vote type

close: https://github.com/koreanbots/serverlist-testing/issues/9

* fix(Constants): fixed category unexpected char

close: https://github.com/koreanbots/serverlist-testing/issues/8

* fix(Query): serialize server data

* fix(Query): returning null on boost level 0

* fix(page/servers): displaying n/a on boostTier null

close: https://github.com/koreanbots/serverlist-testing/issues/4

* fix(pages/servers): hiding emoji list if no emoji

close: https://github.com/koreanbots/serverlist-testing/issues/1

* typo(pages/servers): bot to server

close: https://github.com/koreanbots/serverlist-testing/issues/2

* fix(components/Hero): editing vote list link

close: https://github.com/koreanbots/serverlist-testing/issues/11

* chore(*): changed list route

* feat(pages/servers/list/votes): added server vote list page

close: https://github.com/koreanbots/serverlist-testing/issues/12

* feat(Dockerfile): added pre-build

* fix(Image): image broken when fallbackSrc not given

close: https://github.com/koreanbots/serverlist-testing/issues/5

* ci: checking out submodules

* fix(ServerCard): bot category displayed at ServerCard

close: https://github.com/koreanbots/serverlist-testing/issues/16

* feat(*): supporting opengraph image for server

* fix(utils/Constants): fixed type missing on og

* feat(pages/servers): not forcing emoji width

* chore(utils/Yup): fixed agree checkbox error message

* typo(utils/Yup): fixed bot to server

* feat(pages/servers): improved emoji display

* chore(api/images/discord/icons): removed debug code

* chore(pages/servers): removed crown for owner

close: https://github.com/koreanbots/serverlist-testing/issues/19

* fix(utils/Query): returning date as string

close: https://github.com/koreanbots/serverlist-testing/issues/23

* fix(ServerCard): changed manage link from bot manage link

* fix(ServerCard): same height for every card

* chore: removed debug code

* chore(pages/addserver): showing as invite for server kicked bot

* typo(*): fixed typo issues

* types: added nullable type

* feat(Navbar): added list menu

* chore: showing warning for server data not fetched

* chore: changed main page (combined bots and servers)

* typo(*): replace '한국 디스코드봇 리스트' with '한국 디스코드 리스트'

* chore: added Hero component combined state

* typo: changed name

* fix(Navbar): fix link href

* typo: fix about page for serverlist

* chore: decrease font size

* fix: server category tag link

* fix: bot category link

* feat: added server widget

* fix(ServerCard): fixed servername overflowing

* chore: forcing re-login when discord server data fetch fails

* fix: error causing on owner not registered

* fix: making state same for join button

* fix: filtering owner if null

* fix(servers/[id]): fix error causing if owner is null

* fix(addserver): fixed error occuring for users not logged in

* fix(Constant): fixed og image extension getting popped

* typo: fixed typo issue

* fix: showing forbidden page for non-owner users

* feat: invite guide for server which bot left

* fix: invalid path for paginator on bot page

Co-authored-by: Hajin Lim <zero734kr@gmail.com>
Co-authored-by: Ryu JuHeon <saidbysolo@gmail.com>
This commit is contained in:
Junseo Park 2021-11-06 23:57:46 +09:00 committed by GitHub
parent 1fbb685a38
commit 678fae4112
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 3940 additions and 400 deletions

View File

@ -4,6 +4,11 @@ MYSQL_USER=root
MYSQL_PASSWORD=YOUSHALLNOTPASS
MYSQL_DATABASE=discordbots
MONGO_HOST=mongo
MONGO_USER=discordbots
MONGO_PASSWORD=YOUSHALLNOTPASS
MONGO_DATABASE=discordbots
DISCORD_CLIENT_ID=CLIENT_ID
DISCORD_CLIENT_SECRET=CLIENT_SECRET
DISCORD_SCOPE=SCOPE

43
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Publish
on:
push:
branches: [master]
pull_request: # debug
branches: '*'
tags: '*'
jobs:
image-push:
name: Push docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
- uses: docker/setup-buildx-action@v1
- name: Parse image tag
run: |
parsed=${GITHUB_REF#refs/*/}
echo "RELEASE_TAG=${parsed//\//-}" >> $GITHUB_ENV
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
docker build --build-arg SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN --build-arg NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN --build-arg SENTRY_DSN=$SENTRY_DSN --build-arg SOURCE_COMMIT=${{ env.GITHUB_SHA }} --build-arg TAG=${{ env.RELEASE_TAG }} -t koreanlist .
docker tag koreanlist:latest ${{ secrets.AWS_IMAGE_URL }}:latest
docker tag koreanlist:latest ${{ secrets.AWS_IMAGE_URL }}:${{ env.RELEASE_TAG == 'master' && 'nightly' || env.RELEASE_TAG }}
docker push ${{ secrets.AWS_IMAGE_URL }} --all-tags

View File

@ -24,7 +24,7 @@ jobs:
env:
CI: true
test:
name: Run Test
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@ -34,56 +34,25 @@ jobs:
node-version: 14
- name: yarn install
run: yarn install
- name: Setup MySQL
uses: getong/mariadb-action@v1.1
with:
mysql database: 'discordbots'
mysql root password: 'test'
- name: Run Jest
run: yarn test
- name: Generate RSA Key Pair
run: |
ssh-keygen -b 2048 -t rsa -f key -q -P ""
ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
mv key public.pem
rm key.pub
- name: Setup environments
run: |
mv .env.demo.local .env.production.local
printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install node v14
uses: actions/setup-node@v2
with:
node-version: 14
- name: yarn install
run: yarn install
- name: Build
run: yarn build
run: |
printf 'defaults.url=https://sentry.io/\ndefaults.org=koreanbots\ndefaults.project=client' > sentry.properties
yarn build
env:
CI: true
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# docker:
# needs:
# - eslint
# - build
# - test
# name: Docker Image CI
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v2
# - name: install node v14
# uses: actions/setup-node@v1
# with:
# node-version: 14
# - name: Generate RSA Key Pair
# run: |
# ssh-keygen -b 2048 -t rsa -f key -q -P ""
# ssh-keygen -b 2048 -e -m pem -f key -q -P "" > private.key
# mv key public.pem
# rm key.pub
# - name: Setup environments
# run: |
# mv .env.demo.local .env.production.local
# printf 'MARIADB_ROOT_PASSWORD=YOUSHALLNOTPASS\nCOMMIT_HASH=${{ github.sha }}' > .env
# - name: Create needed files
# run: echo '{"tester":"DEMO_KEY"}' > secret.json
# - name: Docker Compose
# run: docker-compose up -d

View File

@ -2,11 +2,16 @@ import dynamic from 'next/dynamic'
import Link from 'next/link'
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
return <Link href={`/developers/applications/${type + 's'}/${id}`}>
<div className='relative px-2 py-4 text-center dark:bg-discord-black bg-little-white rounded-lg cursor-pointer transform hover:-translate-y-1 transition duration-100 ease-in'>
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' />
{
type === 'bot' ?
<DiscordAvatar userID={id} className='px-2 w-full rounded-xl' /> :
<ServerIcon id={id} className='px-2 w-full rounded-xl' />
}
<h2 className='pt-2 whitespace-nowrap text-xl font-medium truncate'>{name}</h2>
</div>
</Link>
@ -14,7 +19,7 @@ const Application: React.FC<ApplicationProps> = ({ type, id, name }) => {
}
interface ApplicationProps {
type: 'bot'
type: 'bot' | 'server'
id: string
name: string
}

View File

@ -68,7 +68,7 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
<div>
<div className='category flex flex-wrap px-2'>
{bot.category.slice(0, 3).map(el => (
<Tag key={el} text={el} href={`/categories/${el}`} dark />
<Tag key={el} text={el} href={`/bots/categories/${el}`} dark />
))}{' '}
{bot.category.length > 3 && <Tag text={`+${bot.category.length - 3}`} dark />}
</div>
@ -96,8 +96,7 @@ const BotCard: React.FC<BotCardProps> = ({ manage = false, bot }) => {
</a> :
<a
href={
bot.url ||
`https://discordapp.com/oauth2/authorize?client_id=${bot.id}&scope=bot&permissions=0`
makeBotURL(bot) + '/invite'
}
rel='noopener noreferrer'
target='_blank'

View File

@ -13,9 +13,9 @@ const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, c
const [ navbarEnabled, setNavbarOpen ] = useState(false)
return <div className='flex min-h-screen'>
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' openGraph={{
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' openGraph={{
title:'한디리 개발자',
description:'한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.'
description:'한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.'
}} />
<div className='block lg:hidden h-screen relative'>
<div className='w-18 pt-20 px-2 h-full text-center bg-little-white dark:bg-discord-black fixed'>
@ -43,7 +43,7 @@ const DeveloperLayout: React.FC<DeveloperLayout> = ({ children, enabled, docs, c
<Divider className='lg:hidden' />
<Link href='/developers/applications'>
<li className={`cursor-pointer py-2 px-4 rounded-md ${enabled === 'applications' ? 'bg-discord-blurple text-white' : 'hover:text-gray-500 dark:hover:text-white'}`}>
</li>
</Link>
<Link href='/developers/docs'>

View File

@ -1,44 +1,16 @@
import { SyntheticEvent, useEffect, useState } from 'react'
import { SyntheticEvent } from 'react'
import dynamic from 'next/dynamic'
import { KoreanbotsEndPoints } from '@utils/Constants'
import { supportsWebP } from '@utils/Tools'
import Logger from '@utils/Logger'
const Image = dynamic(() => import('@components/Image'))
const DiscordAvatar: React.FC<DiscordAvatarProps> = props => {
const fallback = '/img/default.png'
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>()
useEffect(()=> {
setWebpUnavailable(localStorage.webp === 'false')
}, [])
return <img
alt={props.alt ?? 'Image'}
loading='lazy'
className={props.className}
src={
KoreanbotsEndPoints.CDN.avatar(props.userID, { format: !webpUnavailable ? 'webp' : 'png', size: props.size ?? 256})
}
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> {
if(webpUnavailable) {
(e.target as ImageTarget).onerror = (event) => {
// All Fails
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
(event.target as ImageTarget).src = fallback
}
}
else {
(e.target as ImageTarget).onerror = (event) => {
// All Fails
(event.target as ImageTarget).onerror = ()=> { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
(event.target as ImageTarget).src = fallback
}
// Webp Load Fail
(e.target as ImageTarget).src = KoreanbotsEndPoints.CDN.avatar(props.userID, { size: props.size ?? 256})
if(!supportsWebP()) localStorage.setItem('webp', 'false')
}
}}
return <Image
{...props}
src={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'webp', size: props.size ?? 256})}
fallbackSrc={KoreanbotsEndPoints.CDN.avatar(props.userID, { format: 'png', size: props.size ?? 256})}
/>
}
interface DiscordAvatarProps {

View File

@ -12,8 +12,8 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
<div className='bottom-0 text-white bg-discord-black py-24'>
<Container className='w-11/12 lg:flex lg:pt-0 lg:w-4/5' ignoreColor>
<div className='w-full lg:w-2/5'>
<h1 className='text-koreanbots-blue text-3xl font-bold'> .</h1>
<span className='text-base'>2020-2021 Koreanbots, All rights reserved.</span>
<h1 className='text-koreanbots-blue text-2xl font-bold'> .</h1>
<span className='text-base'>2020-2021 , All rights reserved.</span>
<div className='text-2xl'>
<Link href='/discord'>
<a className='mr-2'>
@ -30,7 +30,7 @@ const Footer: React.FC<FooterProps> = ({ theme, setTheme }) => {
</div>
<div className='grid flex-grow gap-2 grid-cols-2 md:grid-cols-7'>
<div className='col-span-2 mb-2'>
<h2 className='text-koreanbots-blue text-base font-bold'> </h2>
<h2 className='text-koreanbots-blue text-base font-bold'> </h2>
<ul className='text-sm'>
<li>
<Link href='/about'>

View File

@ -1,13 +1,14 @@
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import { categories, categoryIcon } from '@utils/Constants'
import { botCategories, botCategoryIcon, serverCategories, serverCategoryIcon } from '@utils/Constants'
const Container = dynamic(()=> import('@components/Container'))
const Tag = dynamic(()=> import('@components/Tag'))
const Search = dynamic(()=> import('@components/Search'))
const Hero:React.FC<HeroProps> = ({ header, description }) => {
const Hero:React.FC<HeroProps> = ({ type='all', header, description }) => {
const link = `/${type}/categories`
return <>
<NextSeo title={header} description={description} openGraph={{
title: header,
@ -16,23 +17,45 @@ const Hero:React.FC<HeroProps> = ({ header, description }) => {
<div className='dark:bg-discord-black bg-discord-blurple text-gray-100 md:p-0 mb-8'>
<Container className='pt-24 pb-16 md:pb-20' ignoreColor>
<h1 className='hidden md:block text-left text-3xl font-bold'>
{ header && `${header} - `}
{ header && `${header} - `}
</h1>
<h1 className='md:hidden text-center text-3xl font-semibold'>
{ header && <span className='text-4xl'>{header}<br/></span>}
{ header && <span className='text-4xl'>{header}<br/></span>}
</h1>
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || '다양한 국내 디스코드봇을 한곳에서 확인하세요!'}</p>
<p className='text-center sm:text-left text-xl font-base mt-2'>{description || `${type !== 'all' ? '다양한 ' : ''}국내 디스코드${{ all: '의 모든 것을', bots: ' 봇들을', servers: ' 서버들을' }[type]} 한 곳에서 확인하세요!`}</p>
<Search />
<div className='flex flex-wrap mt-5'>
<Tag key='list' text={<>
<i className='fas fa-heart text-red-600'/>
</>} dark bigger href='/list/votes' />
{ categories.slice(0, 4).map(t=> <Tag key={t} text={<>
<i className={categoryIcon[t]} /> {t}
</>} dark bigger href={`/categories/${t}`} />) }
<Tag key='tag' text={<>
<i className='fas fa-tag'/>
</>} dark bigger href='/categories' />
{
type === 'all' ? <>
<Tag text={
<>
<i className='fas fa-robot text-koreanbots-blue'/>
</>
} dark bigger href='/bots' />
<Tag text={
<>
<i className='fas fa-users text-koreanbots-blue'/>
</>
} dark bigger href='/servers' />
{
botCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={botCategoryIcon[t]} /> {t} </>} dark bigger href={`/bots/categories/${t}`} />)
}
{
serverCategories.slice(0, 2).map(t => <Tag key={t} text={<><i className={serverCategoryIcon[t]} /> {t} </>} dark bigger href={`/servers/categories/${t}`} />)
}
</>: <>
<Tag key='list' text={<>
<i className='fas fa-heart text-red-600'/>
</>} dark bigger href={type === 'bots' ? '/bots/list/votes' : '/servers/list/votes'} />
{ (type === 'bots' ? botCategories : serverCategories).slice(0, 4).map(t=> <Tag key={t} text={<>
<i className={(type === 'bots' ? botCategoryIcon : serverCategoryIcon)[t]} /> {t}
</>} dark bigger href={`${link}/${t}`} />) }
<Tag key='tag' text={<>
<i className='fas fa-tag'/>
</>} dark bigger href={link} />
</>
}
</div>
</Container>
</div>
@ -40,6 +63,7 @@ const Hero:React.FC<HeroProps> = ({ header, description }) => {
}
interface HeroProps {
type?: 'all' | 'bots' | 'servers'
header?: string
description?: string
}

62
components/Image.tsx Normal file
View File

@ -0,0 +1,62 @@
import { SyntheticEvent, useEffect, useState } from 'react'
import { supportsWebP } from '@utils/Tools'
import Logger from '@utils/Logger'
const BaseImage: React.FC<ImageProps> = props => {
const fallback = '/img/default.png'
const [ webpUnavailable, setWebpUnavailable ] = useState<boolean>()
useEffect(()=> {
setWebpUnavailable(localStorage.webp === 'false')
}, [])
return <img
alt={props.alt ?? 'Image'}
loading='lazy'
className={props.className}
src={
webpUnavailable && props.fallbackSrc || props.src
}
onError={(e: SyntheticEvent<HTMLImageElement, ImageEvent>)=> {
if(webpUnavailable) {
(e.target as ImageTarget).onerror = (event) => {
// All Fails
(event.target as ImageTarget).onerror = () => { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
(event.target as ImageTarget).src = fallback
}
}
else if (props.fallbackSrc) {
(e.target as ImageTarget).onerror = (event) => {
// All Fails
(event.target as ImageTarget).onerror = () => { Logger.warn('FALLBACK IMAGE LOAD FAIL') }
(event.target as ImageTarget).src = fallback
}
// Webp Load Fail
(e.target as ImageTarget).src = props.fallbackSrc
if(!supportsWebP()) localStorage.setItem('webp', 'false')
}
else {
(e.target as ImageTarget).src = fallback
}
}}
/>
}
interface ImageProps {
alt?: string
className?: string
src: string
fallbackSrc?: string
}
interface ImageEvent extends Event {
target: ImageTarget
}
interface ImageTarget extends EventTarget {
src: string
onerror: (event: SyntheticEvent<HTMLImageElement, ImageEvent>) => void
}
export default BaseImage

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/router'
import { redirectTo } from '@utils/Tools'
import Fetch from '@utils/Fetch'
import { User, UserCache } from '@types'
import { Nullable, User, UserCache } from '@types'
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
@ -18,6 +18,7 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false)
const router = useRouter()
const logged = userCache?.id && userCache.version === 2
const type: Nullable<'bot'|'server'> = router.pathname.startsWith('/bots') ? 'bot' : router.pathname.startsWith('/servers') ? 'server' : null
const dev = router.pathname.startsWith('/developers')
useEffect(() => {
@ -64,6 +65,24 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
</a>
</Link>
</li>
{
type !== 'bot' && <li className='flex items-center'>
<Link href='/bots'>
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
</a>
</Link>
</li>
}
{
type !== 'server' && <li className='flex items-center'>
<Link href='/servers'>
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
</a>
</Link>
</li>
}
<li className='flex items-center'>
<Link href='/discord'>
<a target='_blank' rel='noreferrer' className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'
@ -79,13 +98,24 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
</a>
</Link>
</li>
<li className='flex items-center'>
<Link href='/addbot'>
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
{
type === 'bot' && <li className='flex items-center'>
<Link href='/addbot'>
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
</a>
</Link>
</li>
</a>
</Link>
</li>
}
{
type === 'server' && <li className='flex items-center'>
<Link href='/addserver'>
<a className='lg:hover:text-gray-300 flex items-center px-3 py-4 w-full hover:text-gray-500 text-gray-700 text-sm font-semibold sm:w-auto lg:py-2 lg:text-gray-100'>
</a>
</Link>
</li>
}
</ul>
</div>
<div className='hidden flex-grow items-center bg-white lg:flex lg:bg-transparent lg:shadow-none'>
@ -154,6 +184,22 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
</span>
</a>
</Link>
{
type !== 'bot' && <Link href='/bots'>
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-robot' />
<span className='px-2 font-medium'> </span>
</a>
</Link>
}
{
type !== 'server' && <Link href='/servers'>
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-users' />
<span className='px-2 font-medium'> </span>
</a>
</Link>
}
<Link href='/discord'>
<a target='_blank' rel='noreferrer' onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fab fa-discord' />
@ -166,12 +212,22 @@ const Navbar: React.FC<NavbarProps> = ({ token }) => {
<span className='px-2 font-medium'></span>
</a>
</Link>
<Link href='/addbot'>
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-plus' />
<span className='px-2 font-medium'> </span>
</a>
</Link>
{
type === 'bot' && <Link href='/addbot'>
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-plus' />
<span className='px-2 font-medium'> </span>
</a>
</Link>
}
{
type === 'server' && <Link href='/addserver'>
<a onClick={()=> setNavbarOpen(false)} className='flex items-center px-8 py-2 text-gray-100 hover:text-gray-300'>
<i className='fas fa-plus' />
<span className='px-2 font-medium'> </span>
</a>
</Link>
}
</nav>
<div className='my-10'>

View File

@ -1,7 +1,7 @@
import Link from 'next/link'
import DiscordAvatar from '@components/DiscordAvatar'
const Owner: React.FC<OwnerProps> = ({ id, username, tag }) => {
const Owner: React.FC<OwnerProps> = ({ id, username, tag, crown=false }) => {
return (
<Link href={`/users/${id}`}>
<a className='dark:hover:bg-discord-dark-hover flex mb-1 px-4 py-4 text-black dark:text-gray-400 text-base dark:bg-discord-black bg-little-white hover:bg-little-white-hover rounded cursor-pointer'>
@ -9,8 +9,9 @@ const Owner: React.FC<OwnerProps> = ({ id, username, tag }) => {
<DiscordAvatar userID={id} className='z-negative absolute inset-0 w-full h-full' />
</div>
<div className='flex-1 w-0 leading-snug'>
<h4 className='whitespace-nowrap'>{username}</h4>
<h4 className='whitespace-nowrap'>{ crown && <i className='fas fa-crown text-yellow-300 text-xs' /> }{username}</h4>
<span className='text-gray-600 text-sm'>#{tag}</span>
</div>
</a>
</Link>
@ -23,4 +24,5 @@ interface OwnerProps {
id: string
tag: string
username: string
crown?: boolean
}

View File

@ -3,7 +3,7 @@ import Head from 'next/head'
const SEO: React.FC<SEOProps> = ({ title, description, image }: SEOProps) => {
return (
<Head>
<title>{title} - </title>
<title>{title} - </title>
{description && <meta name='description' content={description} />}
<meta name='og:title' content={title} />
{description && <meta name='og:description' content={description} />}

View File

@ -1,22 +1,25 @@
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import AbortController from 'abort-controller'
import { makeBotURL, redirectTo } from '@utils/Tools'
import { makeBotURL, makeServerURL, redirectTo } from '@utils/Tools'
import Fetch from '@utils/Fetch'
import { BotList, ResponseProps } from '@types'
import { Bot, Server, ResponseProps } from '@types'
import DiscordAvatar from '@components/DiscordAvatar'
import Day from '@utils/Day'
import useOutsideClick from '@utils/useOutsideClick'
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Search: React.FC = () => {
const router = useRouter()
const ref = useRef()
const [query, setQuery] = useState('')
const [recentSearch, setRecentSearch] = useState([])
const [data, setData] = useState<ResponseProps<BotList>>(null)
const [data, setData] = useState<ResponseProps<ListAll>>(null)
const [loading, setLoading] = useState(false)
const [abortControl, setAbortControl] = useState(new AbortController())
const [hidden, setHidden] = useState(true)
@ -32,6 +35,7 @@ const Search: React.FC = () => {
}, [router])
useOutsideClick(ref, () => setHidden(true))
const SearchResults = async (value: string) => {
setData(null)
setQuery(value)
try {
abortControl.abort()
@ -41,7 +45,7 @@ const Search: React.FC = () => {
const controller = new AbortController()
setAbortControl(controller)
if (value.length > 1) setLoading(true)
const res = await Fetch<BotList>(`/search/bots?q=${encodeURIComponent(value)}`, {
const res = await Fetch<ListAll>(`/search/all?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
}).catch((e) => {
if(e.name !== 'AbortError') throw e
@ -105,72 +109,94 @@ const Search: React.FC = () => {
<div className={`relative ${hidden ? 'hidden' : 'block'} z-50`}>
<div className='pin-t pin-l absolute my-2 w-full h-60 text-black dark:text-gray-100 dark:bg-very-black bg-white rounded shadow-md overflow-y-scroll md:h-80'>
<ul>
{data && data.code === 200 && data.data ? (
data.data.data.length === 0 ? (
<li className='px-3 py-3.5'> .</li>
) : (
data.data.data.map(el => (
<Link key={el.id} href={makeBotURL(el)}>
<li className='h-15 flex px-3 py-2 cursor-pointer'>
<DiscordAvatar className='mt-1 w-12 h-12' size={128} userID={el.id} />
<div className='ml-2'>
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1>
<p className='text-gray-400 text-sm'>{el.intro}</p>
</div>
</li>
</Link>
))
)
) : loading ? (
<li className='px-3 py-3.5'>...</li>
) : (
<>
{query && data ? (
data.message?.includes('문법') ? (
<li className='px-3 py-3.5'>
.
<br />
<a
className='hover:text-blue-400 text-blue-500'
href='https://docs.koreanbots.dev/bots/usage/search'
target='_blank'
rel='noreferrer'
>
</a>
</li>
) : <li className='px-3 py-3.5'>{(data.errors && data.errors[0]) || data.message}</li>
) : query.length === 0 ? !recentSearch || !Array.isArray(recentSearch) || recentSearch.length === 0? <li className='px-3 py-3.5'> .</li>
: <>
<li className='h-15 px-3 py-2 cursor-pointer font-semibold'>
<button className='absolute right-0 pr-10 text-sm text-red-500 hover:opacity-90' onClick={() => {
setRecentSearch([])
localStorage.recentSearch = '[]'
}}>
</button>
</li>
{
recentSearch.slice(0, 10).map((el, n) => (
<Link key={n} href={`/search?q=${encodeURIComponent(el?.value)}`}>
<li className='h-15 px-3 py-2 cursor-pointer'>
<i className='fas fa-history' /> {el?.value}
<span className='absolute right-0 pr-10 text-gray-400 text-sm'>
{Day(el?.date).format('MM.DD.')}
</span>
{(data && data.code === 200) ? (
<div className='grid lg:grid-cols-2'>
<ul>
<li className='px-3 py-3.5 font-bold'></li>
{
data.data.bots.length === 0 ?
<li className='px-3 py-3.5'> .</li> :
data.data.bots.map(el => (
<Link key={el.id} href={makeBotURL(el)}>
<li className='h-15 flex px-3 py-2 cursor-pointer'>
<DiscordAvatar className='mt-1 w-12 h-12' size={128} userID={el.id} />
<div className='ml-2'>
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1>
<p className='text-gray-400 text-sm'>{el.intro}</p>
</div>
</li>
</Link>
))
}
</> :
query.length < 3 ? (
'최소 2글자 이상 입력해주세요.'
) : (
'검색어를 입력해주세요.'
)}
</>
)}
}
</ul>
<ul>
<li className='px-3 py-3.5 font-bold'></li>
{
data.data.servers.length === 0 ?
<li className='px-3 py-3.5'> .</li> :
data.data.servers.map(el => (
<Link key={el.id} href={makeServerURL(el)}>
<li className='h-15 flex px-3 py-2 cursor-pointer'>
<ServerIcon className='mt-1 w-12 h-12' size={128} id={el.id} />
<div className='ml-2'>
<h1 className='text-black dark:text-gray-100 text-lg'>{el.name}</h1>
<p className='text-gray-400 text-sm'>{el.intro}</p>
</div>
</li>
</Link>
))
}
</ul>
</div>
) : loading ? <ul>
<li className='px-3 py-3.5'>...</li>
</ul> : <ul>
{query && data ? (
data.message?.includes('문법') ? (
<li className='px-3 py-3.5'>
.
<br />
<a
className='hover:text-blue-400 text-blue-500'
href='https://docs.koreanbots.dev/bots/usage/search'
target='_blank'
rel='noreferrer'
>
</a>
</li>
) : <li className='px-3 py-3.5'>{(data.errors && data.errors[0]) || data.message || '검색중입니다...'}</li>
) : query.length === 0 ? !recentSearch || !Array.isArray(recentSearch) || recentSearch.length === 0? <li className='px-3 py-3.5'> .</li>
: <>
<li className='h-15 px-3 py-2 cursor-pointer font-semibold'>
<button className='absolute right-0 pr-10 text-sm text-red-500 hover:opacity-90' onClick={() => {
setRecentSearch([])
localStorage.recentSearch = '[]'
}}>
</button>
</li>
{
recentSearch.slice(0, 10).map((el, n) => (
<Link key={n} href={`/search?q=${encodeURIComponent(el?.value)}`}>
<li className='h-15 px-3 py-2 cursor-pointer'>
<i className='fas fa-history' /> {el?.value}
<span className='absolute right-0 pr-10 text-gray-400 text-sm'>
{Day(el?.date).format('MM.DD.')}
</span>
</li>
</Link>
))
}
</> :
query.length < 3 ? (
'최소 2글자 이상 입력해주세요.'
) : (
'검색어를 입력해주세요.'
)}
</ul>
}
</ul>
</div>
</div>
@ -179,3 +205,8 @@ const Search: React.FC = () => {
}
export default Search
interface ListAll {
bots: Bot[],
servers: Server[]
}

164
components/ServerCard.tsx Normal file
View File

@ -0,0 +1,164 @@
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { checkServerFlag, formatNumber, makeServerURL } from '@utils/Tools'
import { ServerData, ServerState } from '@types'
import { DiscordEnpoints, DSKR_BOT_ID } from '@utils/Constants'
const Divider = dynamic(() => import('@components/Divider'))
const Tag = dynamic(() => import('@components/Tag'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const ServerCard: React.FC<BotCardProps> = ({ type, server }) => {
const newServerLink = server.data ? `/addserver/${server.id}` : `${DiscordEnpoints.InviteApplication(DSKR_BOT_ID, {}, 'bot', null, server.id)}&disable_guild_select=true`
return <div className='container mb-16 transform hover:-translate-y-1 transition duration-100 ease-in cursor-pointer'>
<div className='relative'>
<div className='container mx-auto'>
<div className='h-full'>
<div
className='relative mx-auto h-full text-black dark:text-white dark:bg-discord-black bg-little-white rounded-2xl shadow-xl'
style={
checkServerFlag(server.flags, 'trusted') && server.banner
? {
background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${server.banner}") center top / cover no-repeat`,
color: 'white',
}
: {}
}
>
<Link href={type !== 'add' ? makeServerURL(server) : newServerLink}>
<div>
<div className='flex h-44'>
<div className='w-3/5'>
<div className='flex justify-start'>
<ServerIcon
size={128}
id={server.id}
hash={type === 'add' && server.icon}
alt='Icon'
className='absolute -left-2 -top-8 mx-auto w-32 h-32 bg-white rounded-full'
/>
</div>
<div className='mt-28 px-4'>
<h2 className={`px-1 text-sm ${server.state !== 'unreachable' ? ' invisible' : ''}`}>
<i className='fas fa-ban text-red-600' />
</h2>
<h1 className='mb-3 text-left text-2xl font-bold truncate'>{server.name}</h1>
</div>
</div>
<div className='grid grid-cols-1 pr-5 py-5 w-2/5 h-0'>
<Tag
text={
<>
<i className='fas fa-heart text-red-600' /> {formatNumber(server.votes)}
</>
}
dark
/>
<Tag
blurple
text={server.members ? <>{formatNumber(server.members)} </> : 'N/A'}
dark
/>
</div>
</div>
<p className='mb-10 px-4 h-6 text-left text-gray-400 text-sm font-medium'>
{type === 'add' ?
server.data ? '지금 바로 서버를 등록할 수 있습니다.' : '봇을 초대해야 서버를 등록할 수 있습니다.'
: server.intro
}
</p>
<div>
<div className='category flex flex-wrap px-2'>
{server.category?.slice(0, 3).map(el => (
<Tag key={el} text={el} href={`/servers/categories/${el}`} dark />
))}{' '}
{server.category?.length > 3 && <Tag text={`+${server.category.length - 3}`} dark />}
</div>
</div>
</div>
</Link>
<Divider />
<div className='w-full'>
<div className='flex justify-evenly'>
{
type === 'add' ?
server.data ? <Link href={newServerLink}>
<a className='py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 rounded-b-2xl hover:shadow-lg transition duration-100 ease-in'>
</a>
</Link> : <Link href={newServerLink}>
<a
className='py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple rounded-b-2xl hover:shadow-lg transition duration-100 ease-in'
rel='noopener noreferrer'
target='_blank'
>
</a>
</Link>
:
<>
<Link href={makeServerURL(server)}>
<a className='py-3 w-full text-center text-koreanbots-blue hover:text-white text-sm font-bold hover:bg-koreanbots-blue rounded-bl-2xl hover:shadow-lg transition duration-100 ease-in'>
</a>
</Link>
{type === 'manage' ? (
<Link href={`/servers/${server.id}/edit`}>
<a className='py-3 w-full text-center text-green-500 hover:text-white text-sm font-bold hover:bg-green-500 rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'>
</a>
</Link>
) : !['ok', 'unreachable'].includes(server.state) ? <a
className='py-3 w-full text-center text-discord-blurple text-sm font-bold rounded-br-2xl hover:shadow-lg transition duration-100 ease-in opacity-50 cursor-default select-none'
>
</a> :
<a
href={
makeServerURL(server) + '/join'
}
rel='noopener noreferrer'
target='_blank'
className='py-3 w-full text-center text-discord-blurple hover:text-white text-sm font-bold hover:bg-discord-blurple rounded-br-2xl hover:shadow-lg transition duration-100 ease-in'
>
</a>
}
</>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
interface BotCardProps {
type: 'list' | 'manage' | 'add'
server: {
id: string,
name: string,
intro?: string
desc?: string,
flags?: number
state?: ServerState
icon: string | null,
banner?: string | null,
bg?: string | null,
vanity?: string | null
category?: string[]
votes?: number | null
members?: number | null,
data?: ServerData
}
}
export default ServerCard

35
components/ServerIcon.tsx Normal file
View File

@ -0,0 +1,35 @@
import { SyntheticEvent } from 'react'
import dynamic from 'next/dynamic'
import { DiscordEnpoints, KoreanbotsEndPoints } from '@utils/Constants'
const Image = dynamic(() => import('@components/Image'))
const ServerIcon: React.FC<ServerIconProps> = ({ id, size, className, alt, hash }) => {
return <Image
className={className}
alt={alt}
src={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'webp', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'webp', size: size ?? 256})}
fallbackSrc={hash ? DiscordEnpoints.CDN.guild(id, hash, { format: 'png', size: size ?? 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'png', size: size ?? 256})}
/>
}
interface ServerIconProps {
alt?: string
id: string
hash?: string
fromDiscord?: boolean
className?: string
size? : 128 | 256 | 512
}
interface ImageEvent extends Event {
target: ImageTarget
}
interface ImageTarget extends EventTarget {
src: string
onerror: (event: SyntheticEvent<HTMLImageElement, ImageEvent>) => void
}
export default ServerIcon

View File

@ -2,9 +2,10 @@ version: '3'
services:
mysql:
container_name: mysql-stable
restart: always
image: wonderlandpark/mariadb-mroonga:latest
hostname: mysql
container_name: mysql-stable
env_file:
- .env
command:
@ -12,12 +13,26 @@ services:
- --collation-server=utf8mb4_unicode_ci
volumes:
- /home/ubuntu/mysql:/var/lib/mysql
mongo:
container_name: mongo-stable
restart: always
image: mongo:5.0
hostname: mongo
env_file:
- .env
volumes:
- /home/ubuntu/mongo:/data/db
web:
container_name: web-stable
restart: always
image: wonderlandpark/koreanbots:nightly
ports:
- 5000:3000
- 3000:3000
links:
- mysql
env_file:
- .env.production.local
image: wonderlandpark/koreanbots:stable
deploy:
resources:
limits:
memory: 500M

View File

@ -2,9 +2,10 @@ version: '3'
services:
mysql:
container_name: mysql
restart: always
image: wonderlandpark/mariadb-mroonga:latest
hostname: mysql
container_name: mysql
env_file:
- .env
command:
@ -12,15 +13,34 @@ services:
- --collation-server=utf8mb4_unicode_ci
volumes:
- /home/ubuntu/mysql-beta:/var/lib/mysql
mongo:
container_name: mongo
restart: always
image: mongo:5.0
hostname: mongo
env_file:
- .env
volumes:
- /home/ubuntu/mongo-beta:/data/db
bot:
container_name: bot
restart: always
image: 397554924689.dkr.ecr.ap-northeast-2.amazonaws.com/dskr
links:
- mongo
env_file:
- .env
web:
container_name: web
restart: always
image: 397554924689.dkr.ecr.ap-northeast-2.amazonaws.com/koreanlist
ports:
- 4000:3000
links:
- mysql
- mongo
env_file:
- .env.beta.production.local
image: wonderlandpark/koreanbots:nightly
deploy:
resources:
limits:

3
next-env.d.ts vendored
View File

@ -1,3 +1,6 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -38,6 +38,7 @@
"josa": "3.0.1",
"jsonwebtoken": "8.5.1",
"knex": "^0.95.8",
"mongoose": "^5.13.4",
"mysql": "2.18.1",
"next": "^11.1.0",
"next-connect": "0.10.1",

View File

@ -59,7 +59,7 @@ const KoreanbotsApp = ({ Component, pageProps, err, cookie }: KoreanbotsProps):
return <div className={theme}>
<DefaultSeo
titleTemplate='%s - 한국 디스코드 리스트'
titleTemplate='%s - 한국 디스코드 리스트'
defaultTitle={TITLE}
description={DESCRIPTION}
openGraph={{

View File

@ -11,26 +11,26 @@ const Container = dynamic(() => import('@components/Container'))
const About:NextPage = () => {
return <div className='pb-10'>
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'> .</h1>} subheader='한국 디스코드봇 리스트에서 자신의 서버에 딱 맞는 봇을 찾아보세요.'>
<Docs title='소개' header={<h1 className='font-black text-4xl dark:text-koreanbots-blue'> .</h1>} subheader='한국 디스코드 리스트에서 자신에게 필요한 디스코드의 모든 것을 찾아보세요!'>
<Container>
<div className='py-1'>
<h1 className='font-bold text-5xl my-5'></h1>
<p className='text-lg'><span className='text-koreanbots-blue font-bold'> </span> , .</p>
<p className='text-lg'> !</p>
<p className='text-lg'><span className='text-koreanbots-blue font-bold'> </span> , .</p>
<p className='text-lg'> !</p>
<Divider />
<h1 className='font-bold text-5xl my-5'></h1>
<div className='grid md:grid-cols-3 gap-12 px-4 pb-5'>
<div className='mx-auto font-normal'>
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'> </h2>
<p className='text-base'> .</p>
<p className='text-base'> .</p>
</div>
<div className='mx-auto font-normal'>
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'> </h2>
<p className='text-base'> , .</p>
<p className='text-base'> , .</p>
</div>
<div className='mx-auto font-normal'>
<h2 className='text-3xl mb-1 font-bold text-koreanbots-blue'>API </h2>
<p className='text-base'>, , svg .<br /> API를 !</p>
<p className='text-base'>, , .<br /> API를 !</p>
</div>
</div>
<Divider />
@ -39,7 +39,7 @@ const About:NextPage = () => {
<Segment>
<h2 className='font-semibold text-xl py-10 text-center'>
<i className='fas fa-quote-left text-xs align-top' />
.
.
<i className='fas fa-quote-right text-xs align-bottom' />
</h2>
</Segment>

View File

@ -10,7 +10,7 @@ import HCaptcha from '@hcaptcha/react-hcaptcha'
import { get } from '@utils/Query'
import { cleanObject, parseCookie, redirectTo } from '@utils/Tools'
import { AddBotSubmit, AddBotSubmitSchema } from '@utils/Yup'
import { categories, library } from '@utils/Constants'
import { botCategories, library } from '@utils/Constants'
import { getToken } from '@utils/Csrf'
import Fetch from '@utils/Fetch'
import { ResponseProps, SubmittedBot, Theme, User } from '@types'
@ -71,13 +71,13 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
}
if(!logged) return <Login>
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
}} />
</Login>
return <Container paddingTop className='py-5'>
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
<NextSeo title='새로운 봇 추가하기' description='자신의 봇을 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 봇 추가하기', description: '자신의 봇을 한국 디스코드 리스트에 등록하세요.'
}} />
<h1 className='text-3xl font-bold'> </h1>
<div className='mt-1 mb-5'>
@ -136,7 +136,7 @@ const AddBot:NextPage<AddBotProps> = ({ logged, user, csrfToken, theme }) => {
<Select options={library.map(el=> ({ label: el, value: el }))} handleChange={(value) => setFieldValue('library', value.value)} handleTouch={() => setFieldTouched('library', true)} />
</Label>
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
<Selects options={categories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
setFieldValue('category', value.map(v=> v.value))
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
<span className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong></span>

216
pages/addserver/[id].tsx Normal file
View File

@ -0,0 +1,216 @@
import { NextPage, NextPageContext } from 'next'
import { useRef, useState } from 'react'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { NextSeo } from 'next-seo'
import { Form, Formik } from 'formik'
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { get } from '@utils/Query'
import { cleanObject, getRandom, parseCookie, redirectTo } from '@utils/Tools'
import { AddServerSubmitSchema, AddServerSubmit } from '@utils/Yup'
import { serverCategories, ServerIntroList } from '@utils/Constants'
import { getToken } from '@utils/Csrf'
import Fetch from '@utils/Fetch'
import { ResponseProps, Server, ServerData, Theme, User } from '@types'
import Forbidden from '@components/Forbidden'
const CheckBox = dynamic(() => import('@components/Form/CheckBox'))
const Label = dynamic(() => import('@components/Form/Label'))
const Login = dynamic(() => import('@components/Login'))
const Input = dynamic(() => import('@components/Form/Input'))
const Divider = dynamic(() => import('@components/Divider'))
const TextArea = dynamic(() => import('@components/Form/TextArea'))
const Segment = dynamic(() => import('@components/Segment'))
const Markdown = dynamic(() => import('@components/Markdown'))
const Selects = dynamic(() => import('@components/Form/Selects'))
const Button = dynamic(() => import('@components/Button'))
const Container = dynamic(() => import('@components/Container'))
const Message = dynamic(() => import('@components/Message'))
const Captcha = dynamic(() => import('@components/Captcha'))
const AddServer:NextPage<AddServerProps> = ({ logged, user, csrfToken, server, serverData, theme }) => {
const [ data, setData ] = useState<ResponseProps<AddServerSubmit>>(null)
const [ captcha, setCaptcha ] = useState(false)
const [ touchedSumbit, setTouched ] = useState(false)
const captchaRef = useRef<HCaptcha>()
const router = useRouter()
const initialValues: AddServerSubmit = {
agree: false,
invite: '',
intro: '',
desc: `<!-- 이 설명을 지우시고 원하시는 설명을 적으셔도 좋습니다! -->
#
!
##
?
?
## 💬
-
-
- ?`,
category: [],
_csrf: csrfToken,
_captcha: 'captcha'
}
function toLogin() {
localStorage.redirectTo = window.location.href
redirectTo(router, 'login')
}
async function submitServer(id: string, value: AddServerSubmit, token: string) {
const res = await Fetch<AddServerSubmit>(`/servers/${id}`, { method: 'POST', body: JSON.stringify(cleanObject<AddServerSubmit>({ ...value, _captcha: token })) })
setData(res)
}
if(!logged) return <Login>
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
}} />
</Login>
return <Container paddingTop className='py-5'>
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
}} />
<h1 className='text-3xl font-bold'> </h1>
<div className='mt-1 mb-5'>
, <span className='font-semibold'>{user.username}#{user.tag}</span>! <a role='button' tabIndex={0} onKeyDown={toLogin} onClick={toLogin} className='text-discord-blurple cursor-pointer outline-none'> ?</a>
</div>
{
data ? data.code == 200 && data.data ? <Message type='success'>
<h2 className='text-lg font-black'> !</h2>
<p> ! ! {redirectTo(router, `/servers/${router.query.id}`)}</p>
</Message> : <Message type='error'>
<h2 className='text-lg font-black'>{data.message || '오류가 발생했습니다.'}</h2>
<ul className='list-disc list-inside'>
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
</ul>
</Message> : <></>
}
{
server ? <Message type='warning'>
<h2 className='text-lg font-black'> .</h2>
</Message> :
!serverData ? <Message type='info'>
<h2 className='text-lg font-black'> .</h2>
<p> .</p>
<p> 1 .</p>
</Message>
: serverData.admins.includes(user.id) || serverData.owner.includes(user.id) ? <Formik initialValues={initialValues}
validationSchema={AddServerSubmitSchema}
onSubmit={() => setCaptcha(true)}>
{({ errors, touched, values, isValid, setFieldTouched, setFieldValue }) => (
<Form>
<div className='py-3'>
<Message type='warning'>
<h2 className='text-lg font-black'> !</h2>
<ul className='list-disc list-inside'>
<li><Link href='/discord'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'> </a></Link> .</li>
<li> <Link href='/guidelines'><a rel='noreferrer' target='_blank' className='text-blue-500 hover:text-blue-600'></a></Link> ?</li>
<li> <strong></strong> .</li>
<li> .</li>
<li>, API에 .</li>
</ul>
</Message>
</div>
<Label For='agree' error={errors.agree && touched.agree ? errors.agree : null} grid={false}>
<div className='flex items-center'>
<CheckBox name='agree' />
<strong className='text-sm ml-2'> , .</strong>
</div>
</Label>
<Divider />
<Label For='id' label='서버' labelDesc='등록하시는 대상 서버 입니다.'>
<p>
<strong>{serverData.name}</strong>
<br/> ID: {router.query.id}
</p>
</Label>
<Divider />
<Label For='category' label='카테고리' labelDesc='서버에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
<Selects options={serverCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
setFieldValue('category', value.map(v=> v.value))
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
<span className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong></span>
</Label>
<Label For='invite' label='서버 초대코드' labelDesc='서버의 초대코드를 입력해주세요. (만료되지 않는 코드로 입력해주세요!)' error={errors.invite && touched.invite ? errors.invite : null} short required>
<div className='flex items-center'>
discord.gg/<Input name='invite' placeholder='JEh53MQ' />
</div>
</Label>
<Divider />
<Label For='intro' label='서버 소개' labelDesc='서버를 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
<Input name='intro' placeholder={getRandom(ServerIntroList)} />
</Label>
<Label For='desc' label='서버 설명' labelDesc={<> ! ( 1500)<br/> !</>} error={errors.desc && touched.desc ? errors.desc : null} required>
<TextArea max={1500} name='desc' placeholder='서버에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} />
</Label>
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다.'>
<Segment>
<Markdown text={values.desc} />
</Segment>
</Label>
<Divider />
<p className='text-base mt-2 mb-5'>
<span className='text-red-500 font-semibold'> *</span> =
</p>
{
captcha ? <Captcha ref={captchaRef} dark={theme === 'dark'} onVerify={(token) => {
submitServer(router.query.id as string, values, token)
window.scrollTo({ top: 0 })
setCaptcha(false)
captchaRef?.current?.resetCaptcha()
}} /> : <>
{
touchedSumbit && !isValid && <div className='my-1 text-red-500 text-xs font-light'> . .</div>
}
<Button type='submit' onClick={() => {
setTouched(true)
if(!isValid) window.scrollTo({ top: 0 })
} }>
<>
<i className='far fa-paper-plane'/>
</>
</Button>
</>
}
</Form>
)}
</Formik>
: <Forbidden />
}
</Container>
}
export const getServerSideProps = async (ctx: NextPageContext) => {
const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token)
const server = await get.server.load(ctx.query.id as string) || null
const serverData = await get.serverData(ctx.query.id as string) || null
return { props: {
logged: !!user, user: await get.user.load(user || ''),
csrfToken: getToken(ctx.req, ctx.res),
server,
serverData: (+new Date() - +new Date(serverData?.updatedAt)) < 2 * 60 * 1000 ? serverData : null
} }
}
interface AddServerProps {
logged: boolean
user: User
csrfToken: string
server: Server | null
serverData: ServerData | null
theme: Theme
}
export default AddServer

56
pages/addserver/index.tsx Normal file
View File

@ -0,0 +1,56 @@
import { NextPage, NextPageContext } from 'next'
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import { get } from '@utils/Query'
import { parseCookie} from '@utils/Tools'
import { RawGuild, ServerData, Theme, User } from '@types'
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Login = dynamic(() => import('@components/Login'))
const Container = dynamic(() => import('@components/Container'))
const AddBot:NextPage<AddBotProps> = ({ logged, guilds }) => {
if(!logged) return <Login>
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
}} />
</Login>
return <Container paddingTop className='py-5'>
<NextSeo title='새로운 서버 추가하기' description='자신의 서버를 한국 디스코드 리스트에 등록하세요.' openGraph={{
title:'새로운 서버 추가하기', description: '자신의 서버를 한국 디스코드 리스트에 등록하세요.'
}} />
<h1 className='text-3xl font-bold'> </h1>
<p className='text-gray-400'> .</p>
<p className='text-gray-400 pb-5'> . , 1 .</p>
<ResponsiveGrid>
{
guilds.sort((a ,b) => (+!!b.data || 0) - (+!!a.data || 0)).map(g => (
<ServerCard type={g.exists ? 'manage' : 'add'} server={g} key={g.id} />
))
}
</ResponsiveGrid>
</Container>
}
export const getServerSideProps = async (ctx: NextPageContext) => {
const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token)
const guilds = (await get.userGuilds.load(user || ''))?.filter(g=> (g.permissions & 8) || g.owner).map(async g => {
const server = (await get.server.load(g.id))
const data = await get.serverData(g.id)
return { ...g, ...(server || {}), ...((+new Date() - +new Date(data?.updatedAt)) < 2 * 60 * 1000 ? { data } : {}), members: data?.memberCount || null, exists: !!server }
})
return { props: { logged: !!user || !!guilds, user: await get.user.load(user || ''), guilds: guilds ? (await Promise.all(guilds)).filter(g => !g?.exists) : null } }
}
interface AddBotProps {
logged: boolean
user: User
csrfToken: string
theme: Theme
guilds: (RawGuild & { data: ServerData, exists?: boolean })[]
}
export default AddBot

View File

@ -0,0 +1,64 @@
import { NextApiRequest } from 'next'
import rateLimit from 'express-rate-limit'
import ResponseWrapper from '@utils/ResponseWrapper'
import { DiscordEnpoints } from '@utils/Constants'
import { get } from '@utils/Query'
import { ImageOptionsSchema } from '@utils/Yup'
import RequestHandler from '@utils/RequestHandler'
const rateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 150,
handler: async (_req, res) => {
const img = await get.images.server.load(DiscordEnpoints.CDN.default(Math.floor(Math.random() * 6), { format: 'png' }))
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'no-cache')
res.send(img)
},
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global')
return false
}
})
const Icon = RequestHandler()
.get(rateLimiter)
.get(async(req: ApiRequest, res) => {
res.setHeader('Access-Control-Allow-Origin', process.env.KOREANBOTS_URL)
const { id: param, size='256' } = req.query
const splitted = param.split('.')
let ext = splitted[1]
const id = splitted[0]
const validated = await ImageOptionsSchema.validate({ id, ext, size }, { abortEarly: false }).then(el=> el).catch(e=> {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
const guild = await get.server.load(id)
let img: Buffer
if(!guild?.icon) img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4))
else img = await get.images.server.load(DiscordEnpoints.CDN.guild(id, guild.icon, { format: validated.ext === 'gif' && !guild.icon.startsWith('a_') ? 'png' : validated.ext }))
if(!img) {
img = await get.images.server.load(DiscordEnpoints.CDN.default(+id % 4, { format: 'png', size: validated.size }))
ext = 'png'
}
res.setHeader('Content-Type', `image/${ext}`)
res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(img)
})
interface ApiRequest extends NextApiRequest {
query: {
id: string
size?: '128' | '256' | '512'
}
}
export default Icon

View File

@ -1,6 +1,6 @@
import { NextApiRequest } from 'next'
import { ResetBotToken, ResetBotTokenSchema } from '@utils/Yup'
import { ResetToken, ResetTokenSchema } from '@utils/Yup'
import { get, update } from '@utils/Query'
import ResponseWrapper from '@utils/ResponseWrapper'
import { checkToken } from '@utils/Csrf'
@ -13,7 +13,7 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await ResetBotTokenSchema.validate(req.body, { abortEarly: false })
const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
@ -30,7 +30,7 @@ const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
})
interface ApiRequest extends NextApiRequest {
body: ResetBotToken
body: ResetToken
query: {
id: string
}

View File

@ -0,0 +1,38 @@
import { NextApiRequest } from 'next'
import { ResetToken, ResetTokenSchema } from '@utils/Yup'
import { get, update } from '@utils/Query'
import ResponseWrapper from '@utils/ResponseWrapper'
import { checkToken } from '@utils/Csrf'
import RequestHandler from '@utils/RequestHandler'
const ResetApplication = RequestHandler().post(async (req: ApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await ResetTokenSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if (!validated) return
const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
if(server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '서버 정보를 불러올 수 없습니다.', errors: ['서버에서 봇이 추방되었거나, 봇이 오프라인이여서 서버 정보를 갱신할 수 없습니다.'] })
if (!(await get.serverOwners(server.id)).find(el => el.id === user)) return ResponseWrapper(res, { code: 403 })
const d = await update.resetServerToken(req.query.id, validated.token)
if (!d) return ResponseWrapper(res, { code: 500, message: '무언가 잘못되었습니다.' })
return ResponseWrapper(res, { code: 200, data: { token: d } })
})
interface ApiRequest extends NextApiRequest {
body: ResetToken
query: {
id: string
}
}
export default ResetApplication

View File

@ -2,11 +2,11 @@ import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { BotList } from '@types'
import { Bot, List } from '@types'
const NewList = RequestHandler().get(async (_req, res) => {
const result = await get.list.new.load(1)
return ResponseWrapper<BotList>(res, { code: 200, data: result })
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
})
export default NewList

View File

@ -2,7 +2,7 @@ import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { BotList } from '@types'
import { Bot, List } from '@types'
import Yup from '@utils/Yup'
const VotesList = RequestHandler().get(async (req, res) => {
@ -13,7 +13,7 @@ const VotesList = RequestHandler().get(async (req, res) => {
})
if(!page) return
const result = await get.list.votes.load(page)
return ResponseWrapper<BotList>(res, { code: 200, data: result })
return ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
})
export default VotesList

View File

@ -0,0 +1,40 @@
import { NextApiRequest } from 'next'
import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { SearchQuerySchema } from '@utils/Yup'
import { Bot, Server, List } from '@types'
const Search = RequestHandler().get(async (req: ApiRequest, res) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: 1 })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
})
if (!validated) return
let botResult: List<Bot>
let serverResult: List<Server>
try {
botResult = await get.list.search.load(
JSON.stringify({ page: validated.page, query: validated.q })
)
serverResult = await get.serverList.search.load(
JSON.stringify({ page: validated.page, query: validated.q })
)
} catch {
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
}
return ResponseWrapper<{ bots: Bot[], servers: Server[] }>(res, { code: 200, data: { bots: botResult?.data || [], servers: serverResult?.data || [] } })
})
interface ApiRequest extends NextApiRequest {
query: {
q?: string
query?: string
}
}
export default Search

View File

@ -5,7 +5,7 @@ import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { SearchQuerySchema } from '@utils/Yup'
import { BotList } from '@types'
import { Bot, List } from '@types'
const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: req.query.page })
@ -15,7 +15,7 @@ const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResp
})
if (!validated) return
let result: BotList
let result: List<Bot>
try {
result = await get.list.search.load(
JSON.stringify({ page: validated.page, query: validated.q })
@ -25,7 +25,7 @@ const SearchBots = RequestHandler().get(async (req: ApiRequest, res: NextApiResp
}
if (result.totalPage < validated.page || result.currentPage !== validated.page)
return ResponseWrapper(res, { code: 404, message: '검색 결과가 없습니다.' })
else ResponseWrapper<BotList>(res, { code: 200, data: result })
else ResponseWrapper<List<Bot>>(res, { code: 200, data: result })
})
interface ApiRequest extends NextApiRequest {

View File

@ -0,0 +1,39 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { SearchQuerySchema } from '@utils/Yup'
import { Server, List } from '@types'
const SearchServers = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
const validated = await SearchQuerySchema.validate({ q: req.query.q || req.query.query, page: req.query.page })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
})
if (!validated) return
let result: List<Server>
try {
result = await get.serverList.search.load(
JSON.stringify({ page: validated.page, query: validated.q })
)
} catch {
return ResponseWrapper(res, { code: 400, message: '검색 문법이 잘못되었습니다.' })
}
if (result.totalPage < validated.page || result.currentPage !== validated.page)
return ResponseWrapper(res, { code: 404, message: '검색 결과가 없습니다.' })
else ResponseWrapper<List<Server>>(res, { code: 200, data: result })
})
interface ApiRequest extends NextApiRequest {
query: {
q?: string
query?: string
page: string
}
}
export default SearchServers

View File

@ -0,0 +1,175 @@
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'
import { checkToken } from '@utils/Csrf'
import { AddServerSubmitSchema, AddServerSubmit, CsrfCaptcha, ManageServerSchema, ManageServer } from '@utils/Yup'
import RequestHandler from '@utils/RequestHandler'
import { checkUserFlag, diff, inspect, makeDiscordCodeblock, objectDiff, serialize } from '@utils/Tools'
import { DiscordBot, discordLog } from '@utils/DiscordBot'
import { KoreanbotsEndPoints } from '@utils/Constants'
const patchLimiter = rateLimit({
windowMs: 2 * 60 * 1000,
max: 2,
handler: (_req, res) => ResponseWrapper(res, { code: 429 }),
keyGenerator: (req) => req.headers['x-forwarded-for'] as string,
skip: (_req, res) => {
res.removeHeader('X-RateLimit-Global')
return false
}
})
const Servers = RequestHandler()
.get(async (req: GetApiRequest, res) => {
const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
else return ResponseWrapper(res, { code: 200, data: server })
})
.post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await AddServerSubmitSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if (!validated) return
const captcha = await CaptchaVerify(validated._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const result = await put.submitServer(user, req.query.id, validated)
if (result === 1)
return ResponseWrapper(res, {
code: 400,
message: '이미 등록된 서버 입니다.'
})
else if (result === 2)
return ResponseWrapper(res, {
code: 406,
message: '봇이 초대되지 않았습니다.',
errors: [
'서버에 봇이 초대되지 않았습니다.',
'이미 봇을 초대하셨다면, 잠시 후 다시 시도해주세요.'
],
})
else if (result === 3)
return ResponseWrapper(res, {
code: 403,
message: '서버의 관리자가 아닙니다.',
errors: [
'해당 서버를 등록할 권한이 없습니다.',
'서버에서 관리자 권한이 있으신지 확인해주세요.'
],
})
else if (result === 4)
return ResponseWrapper(res, {
code: 400,
message: '올바르지 않은 초대 코드 입니다.',
errors: [
'올바른 초대코드를 입력하셨는지 확인해주세요'
],
})
get.user.clear(user)
await discordLog('SERVER/SUBMIT', user, new MessageEmbed().setDescription(`[${req.query.id}](${KoreanbotsEndPoints.URL.server(req.query.id)})`), {
content: inspect(serialize(validated)),
format: 'js'
})
return ResponseWrapper(res, { code: 200, data: result })
})
.delete(async (req: DeleteApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버 입니다.' })
const data = await get.serverData(req.query.id)
if(!data || server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] })
if(![data.owner, ...data.admins].includes(user)) return ResponseWrapper(res, { code: 403 })
const userInfo = await get.user.load(user)
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
if(req.body.name !== server.name) return ResponseWrapper(res, { code: 400, message: '봇 이름을 입력해주세요.' })
await remove.server(server.id)
get.user.clear(user)
await discordLog('SERVER/DELETE', user, (new MessageEmbed().setDescription(`${server.name} - [${server.id}](${KoreanbotsEndPoints.URL.bot(server.id)}))`)),
{
content: inspect(server),
format: 'js'
}
)
return ResponseWrapper(res, { code: 200, message: '성공적으로 삭제했습니다.' })
})
.patch(patchLimiter).patch(async (req: PatchApiRequest, res) => {
const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const user = await get.Authorization(req.cookies.token)
if (!user) return ResponseWrapper(res, { code: 401 })
const userInfo = await get.user.load(user)
const data = await get.serverData(req.query.id)
if(!data || server.state === 'unreachable') return ResponseWrapper(res, { code: 400, message: '해당 서버의 정보를 불러올 수 없습니다.', errors: ['봇이 추방되었거나, 오프라인이 아닌지 확인하시고 다시 시도해주세요.'] })
if(![data.owner, ...data.admins].includes(user) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403 })
if(['reported', 'blocked'].includes(server.state) && !checkUserFlag(userInfo?.flags, 'staff')) return ResponseWrapper(res, { code: 403, message: '해당 서버는 수정할 수 없습니다.', errors: ['오류라고 생각되면 문의해주세요.'] })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const validated = await ManageServerSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if (!validated) return
const invite = await DiscordBot.fetchInvite(validated.invite).catch(() => null)
if(invite?.guild.id !== server.id) return ResponseWrapper(res, { code: 400, message: '올바르지 않은 초대코드입니다.', errors: ['입력하신 초대코드가 올바르지 않습니다. 올바른 초대코드를 입력했는지 다시 한 번 확인해주세요.'] })
const result = await update.server(req.query.id, validated)
if(result === 0) return ResponseWrapper(res, { code: 400 })
else {
get.server.clear(req.query.id)
const embed = new MessageEmbed().setDescription(`${server.name} - ([${server.id}](${KoreanbotsEndPoints.URL.server(server.id)}))`)
const diffData = objectDiff(
{ intro: server.intro, invite: server.invite, category: JSON.stringify(server.category) },
{ intro: validated.intro, invite: validated.invite, category: JSON.stringify(validated.category) },
)
diffData.forEach(d => {
embed.addField(d[0], makeDiscordCodeblock(diff(d[1][0] || '', d[1][1] || ''), 'diff'))
})
await discordLog('SERVER/EDIT', user, embed,
{
content: `--- 설명\n${diff(server.desc, validated.desc, true)}`,
format: 'diff'
}
)
return ResponseWrapper(res, { code: 200 })
}
})
interface GetApiRequest extends NextApiRequest {
query: {
id: string
}
}
interface PostApiRequest extends GetApiRequest {
body: AddServerSubmit | null
}
interface PatchApiRequest extends GetApiRequest {
body: ManageServer | null
}
interface DeleteApiRequest extends GetApiRequest {
body: CsrfCaptcha & { name: string } | null
}
export default Servers

View File

@ -0,0 +1,20 @@
import type { NextApiRequest } from 'next'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { get } from '@utils/Query'
const ServerOwners = RequestHandler()
.get(async (req: GetApiRequest, res) => {
const owners = await get.serverOwners(req.query.id)
if(!owners) return ResponseWrapper(res, { code: 404 })
return ResponseWrapper(res, { code: 200, data: owners })
})
interface GetApiRequest extends NextApiRequest {
query: {
id: string
}
}
export default ServerOwners

View File

@ -0,0 +1,53 @@
import { NextApiRequest } from 'next'
import rateLimit from 'express-rate-limit'
import { get } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { ReportSchema, Report} from '@utils/Yup'
import { getReportChannel } from '@utils/DiscordBot'
import { checkToken } from '@utils/Csrf'
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) => {
res.removeHeader('X-RateLimit-Global')
return false
}
})
const ServerReport = RequestHandler().post(limiter)
.post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id)
if(!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
if(!req.body) return ResponseWrapper(res, { code: 400 })
const validated: Report = await ReportSchema.validate(req.body, { abortEarly: false })
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!validated) return
await getReportChannel().send(`Reported by <@${user}> (${user})\nReported **${server.name}** (${server.id})\nCategory ${req.body.category}\nDesc\n\`\`\`${req.body.description}\`\`\``, { allowedMentions: { parse: ['users'] }})
return ResponseWrapper(res, { code: 200, message: '성공적으로 처리되었습니다.' })
})
interface PostApiRequest extends NextApiRequest {
body: Report | null
query: {
id: string
}
}
export default ServerReport

View File

@ -0,0 +1,57 @@
import { NextApiRequest } from 'next'
import { CaptchaVerify, get, put } from '@utils/Query'
import RequestHandler from '@utils/RequestHandler'
import ResponseWrapper from '@utils/ResponseWrapper'
import { checkToken } from '@utils/Csrf'
import Yup from '@utils/Yup'
import { VOTE_COOLDOWN } from '@utils/Constants'
const ServerVote = RequestHandler()
.get(async (req: GetApiRequest, res) => {
const server = await get.ServerAuthorization(req.headers.authorization)
if(!server) return ResponseWrapper(res, { code: 401 })
if(req.query.id !== server) return ResponseWrapper(res, { code: 403 })
const userID = await Yup.string().required().label('userID').validate(req.query.userID).then(el => el).catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if(!userID) return ResponseWrapper(res, { code: 400 })
const result = await get.vote(userID, server, 'server')
return ResponseWrapper(res, { code: 200, data: { voted: +new Date() < result + VOTE_COOLDOWN, lastVote: result } })
})
.post(async (req: PostApiRequest, res) => {
const user = await get.Authorization(req.cookies.token)
if(!user) return ResponseWrapper(res, { code: 401 })
const server = await get.server.load(req.query.id)
if (!server) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 봇입니다.' })
const csrfValidated = checkToken(req, res, req.body._csrf)
if (!csrfValidated) return
const captcha = await CaptchaVerify(req.body._captcha)
if(!captcha) return ResponseWrapper(res, { code: 400, message: '캡챠 검증에 실패하였습니다.' })
const vote = await put.voteServer(user, server.id)
if(vote === null) return ResponseWrapper(res, { code: 401 })
else if(vote === true) return ResponseWrapper(res, { code: 200 })
else return ResponseWrapper(res, { code: 429, data: { retryAfter: vote } })
})
interface ApiRequest extends NextApiRequest {
query: {
id: string
}
}
interface GetApiRequest extends ApiRequest {
query: {
id: string
userID: string
}
}
interface PostApiRequest extends ApiRequest {
body: {
_captcha: string
_csrf: string
}
}
export default ServerVote

View File

@ -0,0 +1,65 @@
import { NextApiRequest, NextApiResponse } from 'next'
import ResponseWrapper from '@utils/ResponseWrapper'
import { ServerWidgetOptionsSchema } from '@utils/Yup'
import { badgen } from 'badgen'
import { get } from '@utils/Query'
import { ServerBadgeType, DiscordEnpoints } from '@utils/Constants'
import RequestHandler from '@utils/RequestHandler'
const Widget = RequestHandler().get(async (req: ApiRequest, res: NextApiResponse) => {
const { id: param, type, style = 'flat', scale = 1, icon = true } = req.query
const splitted = param.split('.')
const validated = await ServerWidgetOptionsSchema.validate({
id: splitted.slice(0, splitted.length - 1).join('.'),
ext: splitted[splitted.length - 1],
style,
type,
scale,
icon,
})
.then(el => el)
.catch(e => {
ResponseWrapper(res, { code: 400, errors: e.errors })
return null
})
if (!validated) return
const data = await get.server.load(validated.id)
if (!data) return ResponseWrapper(res, { code: 404, message: '존재하지 않는 서버입니다.' })
const userImage = !data.icon
? null
: await get.images.user.load(
DiscordEnpoints.CDN.guild(data.id, data.icon, { format: 'png', size: 128 })
)
const img =
userImage ||
(await get.images.user.load(
DiscordEnpoints.CDN.default(+data.id % 5, { format: 'png', size: 128 })
))
res.setHeader('content-type', 'image/svg+xml; charset=utf-8')
const badgeData = {
...ServerBadgeType(data)[type],
style: validated.style,
scale: validated.scale,
icon: validated.icon ? `data:image/png;base64,${img.toString('base64')}` : null,
}
res.send(badgen(badgeData))
})
interface ApiRequest extends NextApiRequest {
query: {
type: string
id: string
style?: string
scale?: string
icon?: string
}
}
export default Widget

View File

@ -11,7 +11,7 @@ import { getJosaPicker } from 'josa'
import { get } from '@utils/Query'
import { checkUserFlag, cleanObject, makeBotURL, parseCookie, redirectTo } from '@utils/Tools'
import { ManageBot, ManageBotSchema } from '@utils/Yup'
import { categories, library } from '@utils/Constants'
import { botCategories, library } from '@utils/Constants'
import { Bot, Theme, User } from '@types'
import { getToken } from '@utils/Csrf'
import Fetch from '@utils/Fetch'
@ -113,7 +113,7 @@ const ManageBotPage:NextPage<ManageBotProps> = ({ bot, user, csrfToken, theme })
<Select value={{ label: bot.lib, value: bot.lib }} options={library.map(el=> ({ label: el, value: el }))} handleChange={(value) => setFieldValue('library', value.value)} handleTouch={() => setFieldTouched('library', true)} />
</Label>
<Label For='category' label='카테고리' labelDesc='봇에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
<Selects options={categories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
<Selects options={botCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
setFieldValue('category', value.map(v=> v.value))
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
<span className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong></span>

View File

@ -88,7 +88,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
}
</div>
<div className='lg:flex w-full'>
<div className='w-full text-center lg:w-1/4'>
<div className='w-full text-center lg:w-2/12'>
<DiscordAvatar
userID={data.id}
size={256}
@ -108,7 +108,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
{data.name}{' '}
{checkBotFlag(data.flags, 'trusted') ? (
<Tooltip placement='bottom' overlay='해당 봇은 한국 디스코드 리스트에서 엄격한 기준을 통과한 봇입니다!'>
<Tooltip placement='bottom' overlay='해당 봇은 한국 디스코드 리스트에서 엄격한 기준을 통과한 봇입니다!'>
<span className='text-koreanbots-blue text-3xl'>
<i className='fas fa-award' />
</span>
@ -121,10 +121,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
{
data.state === 'ok' && <LongButton
newTab
href={
data.url ||
`https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`
}
href={`/bots/${router.query.id}/invite`}
>
<h4 className='whitespace-nowrap'>
<i className='fas fa-user-plus text-discord-blurple' />
@ -204,7 +201,7 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='flex flex-wrap'>
{data.category.map(el => (
<Tag key={el} text={el} href={`/categories/${el}`} />
<Tag key={el} text={el} href={`/bots/categories/${el}`} />
))}
</div>
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
@ -320,9 +317,9 @@ const Bots: NextPage<BotsProps> = ({ data, desc, date, user, theme, csrfToken })
{
checkBotFlag(data.flags, 'hackerthon') ? <Segment className='mt-10'>
<h1 className='text-3xl font-semibold'>
<i className='fas fa-trophy mr-4 my-2 text-yellow-300' /> !
<i className='fas fa-trophy mr-4 my-2 text-yellow-300' /> !
</h1>
<p> "한국 디스코드 리스트 제1회 해커톤" .</p>
<p> "한국 디스코드 리스트 제1회 해커톤" .</p>
<p> <a className='text-blue-500 hover:text-blue-400' href='https://blog.koreanbots.dev/first-hackathon-results/'> </a> .</p>
</Segment> : ''
}

View File

@ -0,0 +1,20 @@
import { NextPage, GetServerSideProps } from 'next'
import NotFound from 'pages/404'
import { get } from '@utils/Query'
import { Bots } from '@utils/Mongo'
import { getYYMMDD } from '@utils/Tools'
const Invite: NextPage = () => <NotFound />
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const data = await get.bot.load(ctx.query.id as string)
if(!data) return { props: {} }
const record = await Bots.updateOne({ _id: data.id, 'inviteMetrix.day': getYYMMDD() }, { $inc: { 'inviteMetrix.$.count': 1 } })
if(record.n === 0) await Bots.findByIdAndUpdate(data.id, { $push: { inviteMetrix: { count: 1 } } }, { upsert: true })
ctx.res.statusCode = 307
ctx.res.setHeader('Location', data.url || `https://discordapp.com/oauth2/authorize?client_id=${data.id}&scope=bot&permissions=0`)
return {
props: {}
}
}
export default Invite

View File

@ -35,7 +35,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
const router = useRouter()
if(!data?.id) return <NotFound />
if(!user) return <Login>
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
images: [
{
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),
@ -49,7 +49,7 @@ const VoteBot: NextPage<VoteBotProps> = ({ data, user, theme, csrfToken }) => {
if((checkBotFlag(data.flags, 'trusted') || checkBotFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== router.query.id) router.push(`/bots/${data.vanity}/vote?csrfToken=${csrfToken}`)
return <Container paddingTop className='py-10'>
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
images: [
{
url: KoreanbotsEndPoints.CDN.avatar(data.id, { format: 'png', size: 256 }),

View File

@ -3,7 +3,7 @@ import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { get } from '@utils/Query'
import { BotList } from '@types'
import { Bot, List } from '@types'
import { botCategoryListArgumentSchema } from '@utils/Yup'
import NotFound from 'pages/404'
import { useEffect, useState } from 'react'
@ -27,7 +27,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
}, [])
if(!data || data.data.length === 0 || data.totalPage < Number(query.page)) return <NotFound message={data?.data.length === 0 ? '해당 카테고리에 해당되는 봇이 존재하지 않습니다.' : null} />
return <>
<Hero header={`${query.category} 카테고리 봇들`} description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`} />
<Hero type='bots' header={`${query.category} 카테고리 봇들`} description={`다양한 "${query.category}" 카테고리의 봇들을 만나보세요.`} />
{
query.category === 'NSFW' && !nsfw ? <NSFW onClick={() => setNSFW(true)} onDisableClick={() => localStorage.nsfw = true} />
: <Container>
@ -43,7 +43,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/categories/${query.category}`} />
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/bots/categories/${query.category}`} />
<Advertisement />
</Container>
}
@ -51,7 +51,7 @@ const Category: NextPage<CategoryProps> = ({ data, query }) => {
}
export const getServerSideProps = async (ctx: Context) => {
let data: BotList
let data: List<Bot>
if(!ctx.query.page) ctx.query.page = '1'
const validate = await botCategoryListArgumentSchema.validate(ctx.query).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) data = null
@ -65,7 +65,7 @@ export const getServerSideProps = async (ctx: Context) => {
}
interface CategoryProps {
data: BotList
data: List<Bot>
query: URLQuery
}

View File

@ -2,7 +2,7 @@ import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import { categories, categoryIcon } from '@utils/Constants'
import { botCategories, botCategoryIcon } from '@utils/Constants'
const Container = dynamic(() => import('@components/Container'))
@ -12,19 +12,19 @@ const Segment = dynamic(() => import('@components/Segment'))
const Categories:NextPage = () => {
return <Container paddingTop>
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트의 전체 카테고리입니다.' />
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트의 전체 카테고리입니다.' />
<h1 className='text-2xl font-bold mt-2 mb-5'> </h1>
<Segment className='mb-10'>
<div className='text-center flex flex-wrap mt-1.5'>
{
categories.map(t => <Tag key={t} text={<>
botCategories.map(t => <Tag key={t} text={<>
{
{ '빗금 명령어': <span className='fa-stack' style={{ fontSize: '1em', height: '1.2em', lineHeight: '1em', width: '20px', verticalAlign: 'middle' }}>
<i className='fas fa-square fa-stack-1x fa-md' />
<i className='fas fa-slash fa-rotate-90 fa-xs fa-stack-1x fa-inverse' style={{ fontSize: '0.3rem' }} />
</span> }[t] ?? <i className={categoryIcon[t]} />
</span> }[t] ?? <i className={botCategoryIcon[t]} />
} {t}
</>} href={`/categories/${t}`} dark bigger /> )
</>} href={`/bots/categories/${t}`} dark bigger /> )
}
</div>
</Segment>

72
pages/bots/index.tsx Normal file
View File

@ -0,0 +1,72 @@
import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { Bot, List } from '@types'
import * as Query from '@utils/Query'
import LongButton from '@components/LongButton'
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Container = dynamic(() => import('@components/Container'))
const BotCard = dynamic(() => import('@components/BotCard'))
const Paginator = dynamic(() => import('@components/Paginator'))
const Hero = dynamic(() => import('@components/Hero'))
const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
return (
<>
<Hero type='bots' />
<Container className='pb-10'>
<Advertisement />
<h1 className='text-3xl font-bold mt-10 mb-2'>
<i className='far fa-heart mr-3 text-pink-600' />
</h1>
<p className='text-base'> !</p>
<ResponsiveGrid>
{
votes.data.map(bot=> <BotCard key={bot.id} bot={bot} />)
}
</ResponsiveGrid>
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/bots/list/votes' />
<Advertisement />
<h1 className='text-3xl font-bold mb-2'>
<i className='fa fa-check mr-3 mt-10 text-green-500' />
</h1>
<p className='text-base'> !!</p>
<ResponsiveGrid>
{
trusted.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
}
</ResponsiveGrid>
<h1 className='text-3xl font-bold mt-20 mb-2'>
<i className='far fa-star mr-3 text-yellow-500' />
</h1>
<p className='text-base'> .</p>
<ResponsiveGrid>
{
newBots.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
}
</ResponsiveGrid>
<LongButton href='/bots/list/new' center></LongButton>
<Advertisement />
</Container>
</>
)
}
export const getServerSideProps = async() => {
const votes = await Query.get.list.votes.load(1)
const newBots = await Query.get.list.new.load(1)
const trusted = await Query.get.list.trusted.load(1)
return { props: { votes, newBots, trusted }}
}
interface IndexProps {
votes: List<Bot>
newBots: List<Bot>
trusted: List<Bot>
}
export default Index

View File

@ -2,7 +2,7 @@ import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { get } from '@utils/Query'
import { BotList } from '@types'
import { Bot, List } from '@types'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
@ -12,7 +12,7 @@ const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const New:NextPage<NewProps> = ({ data }) => {
return <>
<Hero header='새로운 봇' description='최근에 한국 디스코드 리스트에 추가된 봇들입니다!' />
<Hero type='bots' header='새로운 봇' description='최근에 한국 디스코드 리스트에 추가된 봇들입니다!' />
<Container className='pb-10'>
<Advertisement />
<ResponsiveGrid>
@ -35,7 +35,7 @@ export const getServerSideProps = async () => {
}
interface NewProps {
data: BotList
data: List<Bot>
}
export default New

View File

@ -3,10 +3,10 @@ import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { BotList } from '@types'
import { Bot, List } from '@types'
import { get }from '@utils/Query'
import NotFound from '../404'
import NotFound from '../../404'
import { PageCount } from '@utils/Yup'
const Hero = dynamic(() => import('@components/Hero'))
@ -20,7 +20,7 @@ const Votes:NextPage<VotesProps> = ({ data }) => {
const router = useRouter()
if(!data || data.data.length === 0 || data.totalPage < Number(router.query.page)) return <NotFound />
return <>
<Hero header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!'/>
<Hero type='bots' header='하트 랭킹' description='하트를 많이 받은 봇들의 순위입니다!'/>
<section id='list'>
<Container className='pb-10'>
<Advertisement />
@ -29,14 +29,14 @@ const Votes:NextPage<VotesProps> = ({ data }) => {
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/list/votes' />
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/bots/list/votes' />
<Advertisement />
</Container>
</section>
</>
}
export const getServerSideProps = async (ctx:Context) => {
let data: BotList
let data: List<Bot>
if(!ctx.query.page) ctx.query.page = '1'
const validate = await PageCount.validate(ctx.query.page).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) data = null
@ -49,7 +49,7 @@ export const getServerSideProps = async (ctx:Context) => {
}
interface VotesProps {
data: BotList
data: List<Bot>
}
interface Context extends NextPageContext {

88
pages/bots/search.tsx Normal file
View File

@ -0,0 +1,88 @@
import { NextPage, NextPageContext } from 'next'
import type { FC } from 'react'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { List, Bot } from '@types'
import { get } from '@utils/Query'
import { SearchQuerySchema } from '@utils/Yup'
import { KoreanbotsEndPoints } from '@utils/Constants'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const BotCard = dynamic(() => import('@components/BotCard'))
const Container = dynamic(() => import('@components/Container'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Paginator = dynamic(() => import('@components/Paginator'))
const LongButton = dynamic(() => import('@components/LongButton'))
const Redirect = dynamic(() => import('@components/Redirect'))
const SearchComponent: FC<{data: List<Bot>, query: URLQuery }> = ({ data, query }) => {
return <div className='py-10'>
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'> .</h1> :
<>
<ResponsiveGrid>
{
data.data.map(el => <BotCard key={el.id} bot={el as Bot} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
</>
}
</div>
}
const Search:NextPage<SearchProps> = ({ botData, query }) => {
if(!query?.q) return <Redirect text={false} to='/' />
return <>
<Hero type='bots' header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
<Container>
<section id='list'>
<Advertisement />
<h1 className='text-4xl font-bold'></h1>
<SearchComponent data={botData} query={query} />
<h1 className='text-2xl font-bold py-10'> ?</h1>
<LongButton center href={KoreanbotsEndPoints.URL.searchServer(query.q)}> </LongButton>
<Advertisement />
</section>
</Container>
</>
}
export const getServerSideProps = async(ctx: Context) => {
if(ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query
if(!ctx.query?.q) {
ctx.res.statusCode = 301
ctx.res.setHeader('Location', '/')
return { props: {} }
}
if(!ctx.query.page) ctx.query.page = '1'
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
else {
return {
props: {
botData: await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
query: ctx.query
}
}
}
}
interface SearchProps {
botData?: List<Bot>
query: URLQuery
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
q?: string
query?: string
page?: string
}
export default Search

View File

@ -60,7 +60,7 @@ const BotApplication: NextPage<BotApplicationProps> = ({ user, spec, bot, theme,
</a>
</Link>
<h1 className='text-3xl font-bold'> </h1>
<p className='text-gray-400'> API에 .</p>
<p className='text-gray-400'> API에 .</p>
<div className='lg:flex pt-6'>
<div className='lg:w-1/5'>
<DiscordAvatar userID={bot.id} />
@ -111,7 +111,7 @@ const BotApplication: NextPage<BotApplicationProps> = ({ user, spec, bot, theme,
<Form>
<div className='mb-2'>
<h3 className='font-bold mb-1'> URL</h3>
<p className='text-gray-400 text-sm mb-1'> .</p>
<p className='text-gray-400 text-sm mb-1'> .</p>
<Input name='webhook' placeholder='https://webhook.kbots.link' />
{touched.webhook && errors.webhook ? <div className='text-red-500 text-xs font-light mt-1'>{errors.webhook}</div> : null}
</div>

View File

@ -5,7 +5,7 @@ import { NextSeo } from 'next-seo'
import { get } from '@utils/Query'
import { parseCookie } from '@utils/Tools'
import { Bot, User } from '@types'
import { Bot, Server, User } from '@types'
const Application = dynamic(() => import('@components/Application'))
const DeveloperLayout = dynamic(() => import('@components/DeveloperLayout'))
@ -13,16 +13,23 @@ const Login = dynamic(() => import('@components/Login'))
const Applications: NextPage<ApplicationsProps> = ({ user }) => {
if(!user) return <Login>
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' />
<NextSeo title='한디리 개발자' description='한국 디스코드 리스트 API를 활용하여 봇에 다양한 기능을 추가해보세요.' />
</Login>
return <DeveloperLayout enabled='applications'>
<h1 className='text-3xl font-bold'> </h1>
<p className='text-gray-400'> API를 .</p>
<p className='text-gray-400'> API를 .</p>
<div className='grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2'>
{
(user.bots as Bot[]).map(bot => <Application key={bot.id} id={bot.id} name={bot.name} type='bot' />)
}
</div>
<h1 className='text-3xl font-bold mt-10'> </h1>
<p className='text-gray-400'> API를 .</p>
<div className='grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2'>
{
(user.servers as Server[]).map(bot => <Application key={bot.id} id={bot.id} name={bot.name} type='server' />)
}
</div>
</DeveloperLayout>
}

View File

@ -0,0 +1,163 @@
import { NextPage, NextPageContext } from 'next'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { useState } from 'react'
import useCopyClipboard from 'react-use-clipboard'
import { get } from '@utils/Query'
import { parseCookie, redirectTo } from '@utils/Tools'
import { getToken } from '@utils/Csrf'
import Fetch from '@utils/Fetch'
import { ParsedUrlQuery } from 'querystring'
import { Server, BotSpec, ResponseProps, Theme } from '@types'
import NotFound from 'pages/404'
import Link from 'next/link'
const Button = dynamic(() => import('@components/Button'))
const DeveloperLayout = dynamic(() => import('@components/DeveloperLayout'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Message = dynamic(() => import('@components/Message'))
const Modal = dynamic(() => import('@components/Modal'))
const ServerApplication: NextPage<ServerApplicationProps> = ({ user, spec, server, theme, csrfToken }) => {
const router = useRouter()
const [ data, setData ] = useState<ResponseProps<unknown>>(null)
const [ modalOpened, setModalOpen ] = useState(false)
const [ showToken, setShowToken ] = useState(false)
const [ tokenCopied, setTokenCopied ] = useCopyClipboard(spec?.token, {
successDuration: 1000
})
// async function updateApplication(d: DeveloperBot) {
// const res = await Fetch(`/applications/bots/${bot.id}`, {
// method: 'PATCH',
// body: JSON.stringify(cleanObject(d))
// })
// setData(res)
// }
async function resetToken() {
const res = await Fetch<{ token: string }>(`/applications/servers/${server.id}/reset`, {
method: 'POST',
body: JSON.stringify({ token: spec.token, _csrf: csrfToken })
})
setData(res)
return res
}
if(!user) {
localStorage.redirectTo = window.location.href
redirectTo(router, 'login')
return
}
if(!server) return <NotFound />
return <DeveloperLayout enabled='applications'>
<Link href='/developers/applications'>
<a className='text-blue-500 hover:text-blue-400'>
<i className='fas fa-arrow-left' />
</a>
</Link>
<h1 className='text-3xl font-bold'> </h1>
<p className='text-gray-400'> API에 .</p>
{
spec ? <>
<div className='lg:flex pt-6'>
<div className='lg:w-1/5'>
<ServerIcon id={server.id} />
</div>
<div className='lg:w-4/5 relative'>
<div className='mt-4'>
{
!data ? '' : data.code === 200 ?
<Message type='success'>
<h2 className='text-lg font-black'> !</h2>
<p> .</p>
</Message> : <Message type='error'>
<h2 className='text-lg font-black'>{data.message}</h2>
<ul className='list-disc list-inside'>
{
data.errors?.map((el, i)=> <li key={i}>{el}</li>)
}
</ul>
</Message>
}
</div>
<div className='grid text-left px-6'>
<h2 className='text-3xl font-bold mb-2 mt-3'>{server.name}</h2>
<h3 className='text-lg font-semibold'> </h3>
<pre className='text-sm overflow-x-scroll w-full'>{showToken ? spec.token : '******************'}</pre>
<div className='pt-3 pb-6'>
<Button onClick={() => setShowToken(!showToken)}>{showToken ? '숨기기' : '보기'}</Button>
<Button onClick={setTokenCopied} className={tokenCopied ? 'bg-green-400 text-white' : null}>{tokenCopied ? '복사됨' : '복사'}</Button>
<Button onClick={()=> setModalOpen(true)}></Button>
<Modal isOpen={modalOpened} onClose={() => setModalOpen(false)} dark={theme === 'dark'} header='정말로 토큰을 재발급하시겠습니까?'>
<p> </p>
<div className='text-right pt-6'>
<Button className='bg-gray-500 text-white hover:opacity-90' onClick={()=> setModalOpen(false)}></Button>
<Button onClick={async ()=> {
const res = await resetToken()
if(res.data?.token) spec.token = res.data.token
setModalOpen(false)
}}></Button>
</div>
</Modal>
</div>
{/* <Formik validationSchema={DeveloperBotSchema} initialValues={{
webhook: spec.webhook || '',
_csrf: csrfToken
}}
onSubmit={(data) => updateApplication(data)}>
{({ errors, touched }) => (
<Form>
<div className='mb-2'>
<h3 className='font-bold mb-1'> URL</h3>
<p className='text-gray-400 text-sm mb-1'> .</p>
<Input name='webhook' placeholder='https://webhook.kbots.link' />
{touched.webhook && errors.webhook ? <div className='text-red-500 text-xs font-light mt-1'>{errors.webhook}</div> : null}
</div>
<Button type='submit'><i className='far fa-save'/> </Button>
</Form>
)}
</Formik> */}
</div>
</div>
</div>
</> : <div className='mt-5'>
<Message type='error'>
<h2 className='text-lg font-black'> .</h2>
<p> , .</p>
</Message>
</div>
}
</DeveloperLayout>
}
interface ServerApplicationProps {
user: string
spec: BotSpec
server: Server
csrfToken: string
theme: Theme
}
export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token) || ''
const server = await get.server.load(ctx.query.id)
return {
props: { user, spec: server?.state === 'unreachable' ? null : await get.serverSpec(ctx.query.id, user), server, csrfToken: getToken(ctx.req, ctx.res) }
}
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
id: string
}
export default ServerApplication

View File

@ -1,53 +1,45 @@
import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { BotList } from '@types'
import { Bot, List, Server } from '@types'
import * as Query from '@utils/Query'
import LongButton from '@components/LongButton'
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Container = dynamic(() => import('@components/Container'))
const BotCard = dynamic(() => import('@components/BotCard'))
const Paginator = dynamic(() => import('@components/Paginator'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Hero = dynamic(() => import('@components/Hero'))
const LongButton = dynamic(() => import('@components/LongButton'))
const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
const Index: NextPage<IndexProps> = ({ bots, servers }) => {
return (
<>
<Hero />
<Container className='pb-10'>
<Advertisement />
<h1 className='text-3xl font-bold mt-10 mb-2'>
<i className='far fa-heart mr-3 text-pink-600' />
<i className='fas fa-robot mr-5 text-koreanbots-blue' />
</h1>
<p className='text-base'> !</p>
<ResponsiveGrid>
{
votes.data.map(bot=> <BotCard key={bot.id} bot={bot} />)
bots.data.slice(0, 8).map(bot=> <BotCard key={bot.id} bot={bot} />)
}
</ResponsiveGrid>
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/list/votes' />
<LongButton href='/bots' center> </LongButton>
<Advertisement />
<h1 className='text-3xl font-bold mb-2'>
<i className='fa fa-check mr-3 mt-10 text-green-500' />
<h1 className='text-3xl font-bold mt-10 mb-2'>
<i className='fas fa-users mr-5 text-koreanbots-blue' />
</h1>
<p className='text-base'>KOREANBOTS에서 !!</p>
<p className='text-base'> !</p>
<ResponsiveGrid>
{
trusted.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
servers.data.slice(0, 8).map(bot=> <ServerCard key={bot.id} type='list' server={bot} />)
}
</ResponsiveGrid>
<h1 className='text-3xl font-bold mt-20 mb-2'>
<i className='far fa-star mr-3 text-yellow-500' />
</h1>
<p className='text-base'> .</p>
<ResponsiveGrid>
{
newBots.data.slice(0, 4).map(bot=> <BotCard key={bot.id} bot={bot} />)
}
</ResponsiveGrid>
<LongButton href='/list/new' center></LongButton>
<LongButton href='/servers' center> </LongButton>
<Advertisement />
</Container>
</>
@ -55,18 +47,16 @@ const Index: NextPage<IndexProps> = ({ votes, newBots, trusted }) => {
}
export const getServerSideProps = async() => {
const votes = await Query.get.list.votes.load(1)
const newBots = await Query.get.list.new.load(1)
const trusted = await Query.get.list.trusted.load(1)
const bots = await Query.get.list.votes.load(1)
const servers = await Query.get.serverList.votes.load(1)
return { props: { votes, newBots, trusted }}
return { props: { bots, servers }}
}
interface IndexProps {
votes: BotList
newBots: BotList
trusted: BotList
bots: List<Bot>
servers: List<Server>
}
export default Index

View File

@ -15,7 +15,7 @@ const Opensource: NextPage<OpensourceProps> = ({ packageJson, mainLicense, licen
<span className='text-koreanbots-blue'></span>
<span><i className='text-red-500 fas fa-heart ml-2 mr-3' /> </span>
<span></span>
</h1>} subheader='한국 디스코드봇은 오픈소스 프로젝트이며, 다양한 오픈소스 프로젝트가 사용되었습니다.'>
</h1>} subheader='한국 디스코드 리스트는 오픈소스 프로젝트이며, 다양한 오픈소스 프로젝트가 사용되었습니다.'>
<h1 className='text-3xl font-bold'></h1>
<a href='https://github.com/koreanbots/koreanbots'><i className='fab fa-github'/>Github</a>
<h2 className='text-2xl font-semibold my-2'></h2>

View File

@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
import { get } from '@utils/Query'
import { parseCookie } from '@utils/Tools'
import { Bot, SubmittedBot, User } from '@types'
import { Bot, Server, SubmittedBot, User } from '@types'
import Fetch from '@utils/Fetch'
import { getToken } from '@utils/Csrf'
@ -14,6 +14,7 @@ const Container = dynamic(() => import('@components/Container'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Button = dynamic(() => import('@components/Button'))
const BotCard = dynamic(() => import('@components/BotCard'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const SubmittedBotCard = dynamic(() => import('@components/SubmittedBotCard'))
const Login = dynamic(() => import('@components/Login'))
@ -50,6 +51,17 @@ const Panel:NextPage<PanelProps> = ({ logged, user, submits, csrfToken }) => {
</ResponsiveGrid>
}
</div>
<div className='mt-6'>
<h2 className='text-3xl font-bold'> </h2>
{
user.servers.length === 0 ? <h2 className='text-xl'> .</h2> :
<ResponsiveGrid>
{
(user.servers as Server[]).map(server=> <ServerCard key={server.id} server={server} type='manage' />)
}
</ResponsiveGrid>
}
</div>
<div className='mt-6'>
<h2 className='text-3xl font-bold'> </h2>
{

View File

@ -112,7 +112,7 @@ const PendingBot: NextPage<PendingBotProps> = ({ data }) => {
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='flex flex-wrap'>
{data.category.map(el => (
<Tag key={el} text={el} href={`/categories/${el}`} />
<Tag key={el} text={el} href={`/bots/categories/${el}`} />
))}
</div>
<h2 className='3xl mb-2 mt-2 font-bold'></h2>

View File

@ -1,40 +1,56 @@
import { NextPage, NextPageContext } from 'next'
import type { FC } from 'react'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { BotList } from '@types'
import { List, Bot, Server } from '@types'
import { get } from '@utils/Query'
import { SearchQuerySchema } from '@utils/Yup'
import { KoreanbotsEndPoints } from '@utils/Constants'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const BotCard = dynamic(() => import('@components/BotCard'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Container = dynamic(() => import('@components/Container'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Paginator = dynamic(() => import('@components/Paginator'))
const LongButton = dynamic(() => import('@components/LongButton'))
const Redirect = dynamic(() => import('@components/Redirect'))
const Search:NextPage<SearchProps> = ({ data, query }) => {
if(!query?.q) return <Redirect text={false} to='/' />
return <>
<Hero header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
<section id='list'>
<Container>
<Advertisement />
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'> .</h1> :
<>
<ResponsiveGrid>
{
data.data.map(bot => <BotCard key={bot.id} bot={bot} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
</>
const SearchComponent: FC<{data: List<Bot|Server>, query: URLQuery, type: 'bot' | 'server' }> = ({ data, query, type }) => {
return <div className='py-20'>
<h1 className='text-4xl font-bold'>{type === 'bot' ? '봇' : '서버'}</h1>
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'> .</h1> :
<>
<ResponsiveGrid>
{
data.data.map(el => ( type === 'bot' ? <BotCard key={el.id} bot={el as Bot} /> : <ServerCard key={el.id} type='list' server={el as Server} /> ))
}
</ResponsiveGrid>
{
data.totalPage !== 1 && <LongButton center href={type === 'bot' ? KoreanbotsEndPoints.URL.searchBot(query.q) : KoreanbotsEndPoints.URL.searchServer(query.q)}>
</LongButton>
}
</>
}
</div>
}
const Search:NextPage<SearchProps> = ({ botData, serverData, priority, query }) => {
if(!query?.q) return <Redirect text={false} to='/' />
const list: ('bot'|'server')[] = [ 'bot', 'server' ]
return <>
<Hero type={priority ? priority === 'bot' ? 'bots' : 'servers' : 'all'} header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
<Container>
<section id='list'>
<Advertisement />
</Container>
</section>
{
(priority === 'server' ? list.reverse() : list).map(el => <SearchComponent key={el} data={el === 'bot' ? botData : serverData} query={query} type={el} />)
}
<Advertisement />
</section>
</Container>
</>
}
@ -45,23 +61,26 @@ export const getServerSideProps = async(ctx: Context) => {
ctx.res.setHeader('Location', '/')
return { props: {} }
}
let data: BotList
if(!ctx.query.page) ctx.query.page = '1'
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) data = null
else data = await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null)
return {
props: {
data,
query: ctx.query
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
else {
return {
props: {
botData: await get.list.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
serverData: await get.serverList.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
query: ctx.query,
priority: validate.priority || null
}
}
}
}
interface SearchProps {
data: BotList,
botData?: List<Bot>
serverData?: List<Server>
priority?: 'bot' | 'server'
query: URLQuery
}

View File

@ -14,17 +14,17 @@ const BODY = '중요도:\n설명:\n\n영향을 줄 수 있는 경우:'
const Security: NextPage<SecurityProps> = ({ bugReports }) => {
return <Docs
header='버그 바운티 프로그램'
description='한국 디스코드 리스트는 보안을 최우선으로 생각합니다.'
description='한국 디스코드 리스트는 보안을 최우선으로 생각합니다.'
>
<h1 className='mb-3 text-3xl font-bold text-koreanbots-blue'></h1>
<p> . .</p>
<p> . .</p>
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'></h1>
<ul className='list-disc list-inside'>
<li> . .</li>
<li> . ) , DDoS, DoS </li>
<li> . ) , DDoS, DoS </li>
<li> .</li>
<li> 3 / .</li>
<li> .</li>
<li> .</li>
</ul>
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'></h1>
<ul className='list-disc list-inside'>
@ -34,7 +34,7 @@ const Security: NextPage<SecurityProps> = ({ bugReports }) => {
</ul>
<h1 className='mt-6 mb-3 text-3xl font-bold text-koreanbots-blue'> </h1>
<ul className='list-disc list-inside'>
<li> </li>
<li> </li>
<li>Brute force </li>
<li>Clickjacking</li>
<li>DoS </li>

196
pages/servers/[id]/edit.tsx Normal file
View File

@ -0,0 +1,196 @@
import { NextPage, NextPageContext } from 'next'
import { useState } from 'react'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import { Form, Formik } from 'formik'
import { ParsedUrlQuery } from 'querystring'
import { getJosaPicker } from 'josa'
import { get } from '@utils/Query'
import { checkUserFlag, cleanObject, getRandom, makeServerURL, parseCookie, redirectTo } from '@utils/Tools'
import { ManageServer, ManageServerSchema } from '@utils/Yup'
import { serverCategories, ServerIntroList } from '@utils/Constants'
import { Server, Theme, User } from '@types'
import { getToken } from '@utils/Csrf'
import Fetch from '@utils/Fetch'
import NotFound from 'pages/404'
const Label = dynamic(() => import('@components/Form/Label'))
const Input = dynamic(() => import('@components/Form/Input'))
const Divider = dynamic(() => import('@components/Divider'))
const Redirect = dynamic(() => import('@components/Redirect'))
const TextArea = dynamic(() => import('@components/Form/TextArea'))
const Segment = dynamic(() => import('@components/Segment'))
const Markdown = dynamic(() => import('@components/Markdown'))
const Selects = dynamic(() => import('@components/Form/Selects'))
const Button = dynamic(() => import('@components/Button'))
const Container = dynamic(() => import('@components/Container'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Message = dynamic(() => import('@components/Message'))
const Modal = dynamic(() => import('@components/Modal'))
const Captcha = dynamic(() => import('@components/Captcha'))
const Login = dynamic(() => import('@components/Login'))
const Forbidden = dynamic(() => import('@components/Forbidden'))
const ManageServerPage:NextPage<ManageServerProps> = ({ server, user, owners, csrfToken, theme }) => {
const [ data, setData ] = useState(null)
const [ deleteModal, setDeleteModal ] = useState(false)
const router = useRouter()
async function submitServer(value: ManageServer) {
const res = await Fetch(`/servers/${server.id}`, { method: 'PATCH', body: JSON.stringify(cleanObject<ManageServer>(value)) })
setData(res)
}
if(!server) return <NotFound />
if(!user) return <Login>
<NextSeo title='서버 정보 수정하기' description='서버의 정보를 수정합니다.'/>
</Login>
if(!(owners as User[]).find(el => el.id === user.id) && !checkUserFlag(user.flags, 'staff')) return <Forbidden />
return <Container paddingTop className='pt-5 pb-10'>
<NextSeo title={`${server.name} 수정하기`} description='서버의 정보를 수정합니다.'/>
<h1 className='text-3xl font-bold mb-8'> </h1>
<Formik initialValues={cleanObject({
invite: server.invite,
intro: server.intro,
desc: server.desc,
category: server.category,
_csrf: csrfToken
})}
validationSchema={ManageServerSchema}
onSubmit={submitServer}>
{({ errors, touched, values, setFieldTouched, setFieldValue }) => (
<Form>
<div className='md:flex text-center md:text-left'>
<ServerIcon id={server.id} className='md:mx-1 mx-auto'/>
<div className='md:w-2/3 px-8 py-6'>
<h1 className='text-3xl font-bold'>{server.name}</h1>
<h2>ID: {server.id}</h2>
</div>
</div>
{
data ? data.code === 200 ? <div className='mt-4'>
<Redirect to={makeServerURL(server)}>
<Message type='success'>
<h2 className='text-lg font-black'> .</h2>
<p> !</p>
</Message>
</Redirect>
</div> : <div className='mt-4'>
<Message type='error'>
<h2 className='text-lg font-black'>{data.message || '오류가 발생했습니다.'}</h2>
<ul className='list-disc list-inside'>
{data.errors?.map((el, n) => <li key={n}>{el}</li>)}
</ul>
</Message>
</div> : ''
}
<Label For='category' label='카테고리' labelDesc='서버에 해당되는 카테고리를 선택해주세요' required error={errors.category && touched.category ? errors.category as string : null}>
<Selects options={serverCategories.map(el=> ({ label: el, value: el }))} handleChange={(value) => {
setFieldValue('category', value.map(v=> v.value))
}} handleTouch={() => setFieldTouched('category', true)} values={values.category as string[]} setValues={(value) => setFieldValue('category', value)} />
<span className='text-gray-400 mt-1 text-sm'> 3 . . <strong> .</strong></span>
</Label>
<Label For='invite' label='서버 초대코드' labelDesc='서버의 초대코드를 입력해주세요. (만료되지 않는 코드로 입력해주세요!)' error={errors.invite && touched.invite ? errors.invite : null} short required>
<div className='flex items-center'>
discord.gg/<Input name='invite' placeholder='JEh53MQ' />
</div>
</Label>
<Divider />
<Label For='intro' label='서버 소개' labelDesc='서버를 소개할 수 있는 간단한 설명을 적어주세요. (최대 60자)' error={errors.intro && touched.intro ? errors.intro : null} required>
<Input name='intro' placeholder={getRandom(ServerIntroList)} />
</Label>
<Label For='desc' label='서버 설명' labelDesc={<> ! ( 1500)<br/> !</>} error={errors.desc && touched.desc ? errors.desc : null} required>
<TextArea max={1500} name='desc' placeholder='서버에 대해 최대한 자세히 설명해주세요!' theme={theme === 'dark' ? 'dark' : 'light'} value={values.desc} setValue={(value) => setFieldValue('desc', value)} />
</Label>
<Label For='preview' label='설명 미리보기' labelDesc='다음 결과는 실제와 다를 수 있습니다.'>
<Segment>
<Markdown text={values.desc} />
</Segment>
</Label>
<Divider />
<p className='text-base mt-2 mb-5'>
<span className='text-red-500 font-semibold'> *</span> =
</p>
<Button type='submit' onClick={() => window.scrollTo({ top: 0 })}>
<>
<i className='far fa-save'/>
</>
</Button>
</Form>
)}
</Formik>
{
(checkUserFlag(user.flags, 'staff') || server.owner?.id === user.id) && <div className='py-4'>
<Divider />
<h2 className='text-2xl font-semibold pb-2'></h2>
<p className='text-gray-400 mb-3'> . "관리자" .</p>
<Segment>
<div className='lg:flex items-center'>
<div className='flex-grow py-1'>
<h3 className='text-lg font-semibold'> </h3>
<p className='text-gray-400'> .</p>
</div>
<Button onClick={() => setDeleteModal(true)} className='h-10 bg-red-500 hover:opacity-80 text-white lg:w-1/8'><i className='fas fa-trash' /> </Button>
<Modal full header={`${server.name} 삭제하기`} isOpen={deleteModal} dark={theme === 'dark'} onClose={() => setDeleteModal(false)} closeIcon>
<Formik initialValues={{ name: '', _captcha: '', _csrf: csrfToken }} onSubmit={async (v) => {
const res = await Fetch(`/servers/${server.id}`, { method: 'DELETE', body: JSON.stringify(v) })
if(res.code === 200) {
alert('성공적으로 삭제하였습니다.')
redirectTo(router, '/')
}
else alert(res.message)
}}>
{
({ values, setFieldValue }) => <Form>
<Message type='warning'>
<p> .<br/> .</p>
<p> <strong>{server.name}</strong>{getJosaPicker('을')(server.name)} .</p>
</Message>
<div className='py-4'>
<Input name='name' placeholder={server.name} />
</div>
<Captcha dark={theme === 'dark'} onVerify={(k) => setFieldValue('_captcha', k)} />
<Button disabled={values.name !== server.name || !values._captcha} className={`mt-4 bg-red-500 text-white ${values.name !== server.name || !values._captcha ? 'opacity-80' : 'hover:opacity-80'}`} type='submit'><i className='fas fa-trash' /> </Button>
</Form>
}
</Formik>
</Modal>
</div>
</Segment>
</div>
}
</Container>
}
export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req)
const user = await get.Authorization(parsed?.token)
return { props: {
server: await get.server.load(ctx.query.id),
user: await get.user.load(user || ''),
owners: await get.serverOwners(ctx.query.id),
csrfToken: getToken(ctx.req, ctx.res)
} }
}
interface ManageServerProps {
server: Server
user: User
owners: User[]
csrfToken: string
theme: Theme
}
interface Context extends NextPageContext {
query: Query
}
interface Query extends ParsedUrlQuery {
id: string
}
export default ManageServerPage

View File

@ -0,0 +1,319 @@
import { useState, useEffect } from 'react'
import { NextPage, NextPageContext } from 'next'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { NextSeo } from 'next-seo'
import Tooltip from 'rc-tooltip'
import { SnowflakeUtil } from 'discord.js'
import { ParsedUrlQuery } from 'querystring'
import { Server, Theme, User } from '@types'
import { DiscordEnpoints, DSKR_BOT_ID, KoreanbotsEndPoints } from '@utils/Constants'
import { get, safeImageHost } from '@utils/Query'
import Day from '@utils/Day'
import Fetch from '@utils/Fetch'
import { checkBotFlag, checkServerFlag, checkUserFlag, formatNumber, parseCookie } from '@utils/Tools'
import { getToken } from '@utils/Csrf'
import NotFound from '../../404'
const Container = dynamic(() => import('@components/Container'))
const Image = dynamic(() => import('@components/Image'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Divider = dynamic(() => import('@components/Divider'))
const Tag = dynamic(() => import('@components/Tag'))
const Owner = dynamic(() => import('@components/Owner'))
const Segment = dynamic(() => import('@components/Segment'))
const LongButton = dynamic(() => import('@components/LongButton'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const Markdown = dynamic(() => import ('@components/Markdown'))
const Message = dynamic(() => import('@components/Message'))
const Modal = dynamic(() => import('@components/Modal'))
const Servers: NextPage<ServersProps> = ({ data, desc, date, user, theme }) => {
const [ emojisModal, setEmojisModal ] = useState(false)
const [ ownersModal, setOwnersModal ] = useState(false)
const [ owners, setOwners ] = useState<User[]>(null)
const bg = checkBotFlag(data?.flags, 'trusted') && data?.banner
const router = useRouter()
useEffect(() => {
if(data) Fetch<User[]>(`/servers/${data.id}/owners`).then(async res => {
if(res?.code === 200) setOwners(res.data)
})
}, [ data ])
if (!data?.id) return <NotFound />
return <div style={bg ? { background: `linear-gradient(to right, rgba(34, 36, 38, 0.68), rgba(34, 36, 38, 0.68)), url("${data.bg}") center top / cover no-repeat fixed` } : {}}>
<Container paddingTop className='py-10'>
<NextSeo
title={data.name}
description={data.intro}
twitter={{
cardType: 'summary_large_image'
}}
openGraph={{
images: [
{
url: KoreanbotsEndPoints.OG.server(data.id, data.name, data.intro, data.category, [formatNumber(data.votes), formatNumber(data.members)]),
width: 2048,
height: 1170,
alt: 'Server Preview Image'
}
]
}}
/>
{
data.state === 'blocked' ? <div className='pb-40'>
<Message type='error'>
<h2 className='text-lg font-black'> .</h2>
</Message>
</div>
: <>
<div className='w-full pb-2'>
{
data.state === 'unreachable' ? <Message type='error'>
<h2 className='text-lg font-black'> .</h2>
<p> , .</p>
{
owners?.find(el => el.id === user?.id) && <>
<h3 className='text-md font-bold pt-2'> !</h3>
<p> <a className='text-blue-600 hover:text-blue-500 cursor-pointer' href={`${DiscordEnpoints.InviteApplication(DSKR_BOT_ID, {}, 'bot', null, data.id)}&disable_guild_select=true`}></a> !</p>
</>
}
</Message> :
data.state === 'reported' ?
<Message type='error'>
<h2 className='text-lg font-black'> , .</h2>
<p> .</p>
<p> <Link href='/guidelines'><a className='text-blue-500 hover:text-blue-400'></a></Link> <Link href='/discord'><a className='text-blue-500 hover:text-blue-400'> </a></Link> .</p>
</Message> : ''
}
</div>
<div className='lg:flex w-full'>
<div className='w-full text-center lg:w-2/12'>
<ServerIcon
id={data.id}
size={256}
className='w-full'
/>
</div>
<div className='flex-grow px-5 py-12 w-full text-center lg:w-5/12 lg:text-left'>
<h1 className='mb-2 mt-3 text-4xl font-bold' style={bg ? { color: 'white' } : {}}>
{data.name}{' '}
{checkServerFlag(data.flags, 'trusted') ? (
<Tooltip placement='bottom' overlay='해당 서버는 한국 디스코드 리스트에서 엄격한 기준을 통과한 서버 입니다!'>
<span className='text-koreanbots-blue text-3xl'>
<i className='fas fa-award' />
</span>
</Tooltip>
) : ''}
</h1>
<p className={`${bg ? 'text-gray-300' : 'dark:text-gray-300 text-gray-800'} text-base`}>{data.intro}</p>
</div>
<div className='w-full lg:w-1/4'>
{
['ok', 'unreachable'].includes(data.state) && <LongButton
newTab
href={`/servers/${router.query.id}/join`}
>
<h4 className='whitespace-nowrap'>
<i className='fas fa-user-plus text-discord-blurple' />
</h4>
</LongButton>
}
<Link href={`/servers/${router.query.id}/vote`}>
<LongButton>
<h4>
<i className='fas fa-heart text-red-600' />
</h4>
<span className='ml-1 px-2 text-center text-black dark:text-gray-400 text-sm bg-little-white-hover dark:bg-very-black rounded-lg'>
{formatNumber(data.votes)}
</span>
</LongButton>
</Link>
{
(owners?.find(el => el.id === user?.id) || checkUserFlag(user?.flags, 'staff')) && <>
<LongButton href={`/servers/${data.id}/edit`}>
<h4>
<i className='fas fa-cogs' />
</h4>
</LongButton>
{/* <LongButton onClick={async() => {
const res = await Fetch(`/servers/${data.id}/stats`, { method: 'PATCH'} )
if(res.code !== 200) return alert(res.message)
else window.location.reload()
}}>
<h4>
<i className='fas fa-sync' />
</h4>
</LongButton> */}
</>
}
</div>
</div>
<Divider className='px-5' />
<div className='hidden lg:block'>
<Advertisement />
</div>
<div className='lg:flex lg:flex-row-reverse' style={bg ? { color: 'white' } : {}}>
<div className='mb-1 w-full lg:w-1/4'>
<h2 className='3xl mb-2 font-bold'></h2>
<div className='grid gap-4 grid-cols-2 px-4 py-4 text-black dark:text-gray-400 dark:bg-discord-black bg-little-white rounded-sm'>
<div>
<i className='fas fa-users' />
</div>
<div>{data.members || 'N/A'}</div>
<div>
<i className='far fa-gem' />
</div>
<div>{typeof data.boostTier === 'number' ? `${data.boostTier}레벨` : 'N/A'}</div>
<div>
<i className='fas fa-calendar-day' />
</div>
<div>{Day(date).fromNow(false)}</div>
{
checkServerFlag(data.flags, 'discord_partnered') ?
<Tooltip overlay='해당 서버는 디스코드 파트너 입니다.'>
<div className='col-span-2'>
<i className='fas fa-infinity text-discord-blurple' />
</div>
</Tooltip>
: ''
}
{
checkServerFlag(data.flags, 'verified') ?
<Tooltip overlay='해당 서버는 디스코드에서 인증된 서버입니다.'>
<div className='col-span-2'>
<i className='fas fa-check text-discord-blurple' />
</div>
</Tooltip>
: ''
}
</div>
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='flex flex-wrap'>
{data.category.map(el => (
<Tag key={el} text={el} href={`/servers/categories/${el}`} />
))}
</div>
{
data.emojis.length !== 0 && <>
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
<div className='flex flex-wrap'>
{
data.emojis.slice(0, 5).map(el => <Image src={el.url} key={el.name} className='h-8 m-1' />)
}
{
data.emojis.length > 5 && <Tag className='cursor-pointer' onClick={() => setEmojisModal(true)} text={`+${data.emojis.length - 5}`} />
}
</div>
<Modal header='이모지 전체보기' closeIcon isOpen={emojisModal} onClose={() => setEmojisModal(false)}
full dark={theme === 'dark'}>
<strong>{data.emojis.length}</strong> .
<div className='flex flex-wrap'>
{
data.emojis.map(el => <Tooltip zIndex={1000} key={el.name} placement='top' overlay={`:${el.name}:`} mouseLeaveDelay={0}>
<div>
<Image src={el.url} className='h-8 m-1' />
</div>
</Tooltip>)
}
</div>
</Modal>
</>
}
<h2 className='3xl mb-2 mt-2 font-bold'></h2>
{
data.owner && <Owner
key={data.owner.id}
id={data.owner.id}
tag={data.owner.tag}
username={data.owner.username}
/>
}
<LongButton onClick={() => setOwnersModal(true)}> </LongButton>
<Modal header='관리자 전체보기' closeIcon isOpen={ownersModal} onClose={() => setOwnersModal(false)}
full dark={theme === 'dark'}>
<div className='grid gap-x-1 grid-rows-1 md:grid-cols-2'>
{owners ? owners.map(el => (
<Owner
key={el.id}
id={el.id}
tag={el.tag}
username={el.username}
crown={el.id === data.owner?.id}
/>
)) : <strong> ...</strong>}
</div>
</Modal>
<div className='list grid'>
<Link href={`/servers/${router.query.id}/report`}>
<a className='text-red-600 hover:underline cursor-pointer' aria-hidden='true'>
<i className='far fa-flag' />
</a>
</Link>
</div>
<Advertisement size='tall' />
</div>
<div className='w-full lg:pr-5 lg:w-3/4'>
<Segment className='my-4'>
<Markdown text={desc}/>
</Segment>
<Advertisement />
</div>
</div>
</>
}
</Container>
</div>
}
export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req)
const data = await get.server.load(ctx.query.id)
if(!data) return {
props: {
data
}
}
const desc = safeImageHost(data.desc)
const user = await get.Authorization(parsed?.token)
if((checkServerFlag(data.flags, 'trusted') || checkServerFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== ctx.query.id) {
ctx.res.statusCode = 301
ctx.res.setHeader('Location', `/servers/${data.vanity}`)
return {
props: {}
}
}
return {
props: {
data,
desc,
date: SnowflakeUtil.deconstruct(data.id ?? '0').date.toJSON(),
user: await get.user.load(user || ''),
csrfToken: getToken(ctx.req, ctx.res)
},
}
}
export default Servers
interface ServersProps {
data: Server
desc: string
date: Date
user: User
theme: Theme
csrfToken: string
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
id: string
}

View File

@ -0,0 +1,19 @@
import { NextPage, GetServerSideProps } from 'next'
import NotFound from 'pages/404'
import { get } from '@utils/Query'
import { DiscordEnpoints } from '@utils/Constants'
const Join: NextPage = () => <NotFound />
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const data = await get.server.load(ctx.query.id as string)
if(!data) return { props: {} }
// // const record = await Bots.updateOne({ _id: data.id, 'inviteMetrix.day': getYYMMDD() }, { $inc: { 'inviteMetrix.$.count': 1 } })
// if(record.n === 0) await Bots.findByIdAndUpdate(data.id, { $push: { inviteMetrix: { count: 1 } } }, { upsert: true })
ctx.res.statusCode = 307
ctx.res.setHeader('Location', DiscordEnpoints.ServerInvite(data.invite))
return {
props: {}
}
}
export default Join

View File

@ -0,0 +1,140 @@
import { NextPage } from 'next'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { Field, Form, Formik } from 'formik'
import { Server, CsrfContext, ResponseProps, User } from '@types'
import { get } from '@utils/Query'
import { makeServerURL, parseCookie } from '@utils/Tools'
import { ParsedUrlQuery } from 'querystring'
import NotFound from 'pages/404'
import { getToken } from '@utils/Csrf'
import { DMCA, TextField } from '@components/ReportTemplate'
import { useState } from 'react'
import Fetch from '@utils/Fetch'
import { ReportSchema } from '@utils/Yup'
import { getJosaPicker } from 'josa'
import { serverReportCats } from '@utils/Constants'
import { NextSeo } from 'next-seo'
const Container = dynamic(() => import('@components/Container'))
const Message = dynamic(() => import('@components/Message'))
const Login = dynamic(() => import('@components/Login'))
const ReportServer: NextPage<ReportServerProps> = ({ data, user, csrfToken }) => {
const [ reportRes, setReportRes ] = useState<ResponseProps<unknown>>(null)
if(!data?.id) return <NotFound />
if(!user) return <Login>
<NextSeo title='신고하기' />
</Login>
return <Container paddingTop className='py-10'>
<NextSeo title={`${data.name} 신고하기`} />
<Link href={makeServerURL(data)}>
<a className='text-blue-500 hover:opacity-80'><i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} </a>
</Link>
{
reportRes?.code === 200 ? <Message type='success'>
<h2 className='text-lg font-semibold'> !</h2>
<p> . <strong> <a className='text-blue-600 hover:text-blue-500' href='/discord'> </a> !!</strong></p>
</Message> : <Formik onSubmit={async (body) => {
const res = await Fetch(`/servers/${data.id}/report`, { method: 'POST', body: JSON.stringify(body) })
setReportRes(res)
}} validationSchema={ReportSchema} initialValues={{
category: null,
description: '',
_csrf: csrfToken
}}>
{
({ errors, touched, values, setFieldValue }) => (
<Form>
<div className='mb-5'>
{
reportRes && <div className='my-5'>
<Message type='error'>
<h2 className='text-lg font-semibold'>{reportRes.message}</h2>
<ul className='list-disc'>
{reportRes.errors?.map((el, n) => <li key={n}>{el}</li>)}
</ul>
</Message>
</div>
}
<h3 className='font-bold'> </h3>
<p className='text-gray-400 text-sm mb-1'> .</p>
{
serverReportCats.map(el =>
<div key={el}>
<label>
<Field type='radio' name='category' value={el} className='mr-1.5 py-2' />
{el}
</label>
</div>
)
}
<div className='mt-1 text-red-500 text-xs font-light'>{errors.category && touched.category ? errors.category : null}</div>
{
values.category && <>
{
{
[serverReportCats[1]]: <Message type='info'>
<h3 className='font-bold text-xl'> ?</h3>
<p> .</p>
<p className='list-disc list-item list-inside'> 1393 | 1388</p>
</Message>,
[serverReportCats[3]]: <DMCA values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} />,
[serverReportCats[4]]: <Message type='warning'>
<h3 className='font-bold text-xl'> ?</h3>
<p><a className='text-blue-400' target='_blank' rel='noreferrer' href='http://dis.gd/report'> </a> .</p>
</Message>
}[values.category]
}
{
![serverReportCats[3]].includes(values.category) && <>
<h3 className='font-bold mt-2'></h3>
<p className='text-gray-400 text-sm mb-1'> .</p>
<TextField values={values} errors={errors} touched={touched} setFieldValue={setFieldValue} />
</>
}
</>
}
</div>
</Form>
)
}
</Formik>
}
</Container>
}
export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req)
const data = await get.server.load(ctx.query.id)
const user = await get.Authorization(parsed?.token)
return {
props: {
csrfToken: getToken(ctx.req, ctx.res),
data,
user: await get.user.load(user || '')
},
}
}
interface ReportServerProps {
csrfToken: string
data: Server
user: User
}
interface Context extends CsrfContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
id: string
}
export default ReportServer

135
pages/servers/[id]/vote.tsx Normal file
View File

@ -0,0 +1,135 @@
import { NextPage } from 'next'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { CsrfContext, ResponseProps, Server, Theme, User } from '@types'
import { get } from '@utils/Query'
import { parseCookie, checkServerFlag, makeServerURL } from '@utils/Tools'
import { ParsedUrlQuery } from 'querystring'
import NotFound from 'pages/404'
import { getToken } from '@utils/Csrf'
import Captcha from '@components/Captcha'
import { useState } from 'react'
import Fetch from '@utils/Fetch'
import Day from '@utils/Day'
import { getJosaPicker } from 'josa'
import { KoreanbotsEndPoints } from '@utils/Constants'
import { NextSeo } from 'next-seo'
const Container = dynamic(() => import('@components/Container'))
const ServerIcon = dynamic(() => import('@components/ServerIcon'))
const Button = dynamic(() => import('@components/Button'))
const Tag = dynamic(() => import('@components/Tag'))
const Segment = dynamic(() => import('@components/Segment'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const Login = dynamic(() => import('@components/Login'))
const Message = dynamic(() => import('@components/Message'))
const VoteServer: NextPage<VoteServerProps> = ({ data, user, theme, csrfToken }) => {
const [ votingStatus, setVotingStatus ] = useState(0)
const [ result, setResult ] = useState<ResponseProps<{retryAfter?: number}>>(null)
const router = useRouter()
if(!data?.id) return <NotFound />
if(!user) return <Login>
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
images: [
{
url: KoreanbotsEndPoints.CDN.icon(data.id, { format: 'png', size: 256 }),
width: 256,
height: 256,
alt: 'Server Icon'
}
]
}} />
</Login>
if((checkServerFlag(data.flags, 'trusted') || checkServerFlag(data.flags, 'partnered')) && data.vanity && data.vanity !== router.query.id) router.push(`/servers/${data.vanity}/vote?csrfToken=${csrfToken}`)
return <Container paddingTop className='py-10'>
<NextSeo title={data.name} description={`한국 디스코드 리스트에서 ${data.name}에 투표하세요.`} openGraph={{
images: [
{
url: KoreanbotsEndPoints.CDN.icon(data.id, { format: 'png', size: 256 }),
width: 256,
height: 256,
alt: 'Server Avatar'
}
]
}} />
{
data.state === 'blocked' ? <div className='pb-40'>
<Message type='error'>
<h2 className='text-lg font-black'> .</h2>
</Message>
</div> : <>
<Advertisement />
<Link href={makeServerURL(data)}>
<a className='text-blue-500 hover:opacity-80'><i className='fas fa-arrow-left mt-3 mb-3' /> <strong>{data.name}</strong>{getJosaPicker('로')(data.name)} </a>
</Link>
<Segment className='mb-16 py-8'>
<div className='text-center'>
<ServerIcon id={data.id} className='mx-auto w-52 h-52 bg-white mb-4' />
<Tag text={<span><i className='fas fa-heart text-red-600' /> {data.votes}</span>} dark />
<h1 className='text-3xl font-bold mt-3'>{data.name}</h1>
<h4 className='text-md mt-1'>12 .</h4>
<div className='inline-block mt-2'>
{
votingStatus === 0 ? <Button onClick={()=> setVotingStatus(1)}>
<><i className='far fa-heart text-red-600'/> </>
</Button>
: votingStatus === 1 ? <Captcha dark={theme === 'dark'} onVerify={async (key) => {
const res = await Fetch<{ retryAfter: number }|unknown>(`/servers/${data.id}/vote`, { method: 'POST', body: JSON.stringify({ _csrf: csrfToken, _captcha: key }) })
setResult(res)
setVotingStatus(2)
}}
/>
: result.code === 200 ? <h2 className='text-2xl font-bold'> !</h2>
: result.code === 429 ? <>
<h2 className='text-2xl font-bold'> .</h2>
<h4 className='text-md mt-1'>{Day(+new Date() + result.data?.retryAfter).fromNow()} .</h4>
</>
: <p>{result.message}</p>
}
</div>
</div>
</Segment>
<Advertisement /></>
}
</Container>
}
export const getServerSideProps = async (ctx: Context) => {
const parsed = parseCookie(ctx.req)
const data = await get.server.load(ctx.query.id)
const user = await get.Authorization(parsed?.token)
return {
props: {
csrfToken: getToken(ctx.req, ctx.res),
data,
user: await get.user.load(user || '')
},
}
}
interface VoteServerProps {
csrfToken: string
vote: boolean
data: Server
user: User
theme: Theme
}
interface Context extends CsrfContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
id: string
}
export default VoteServer

View File

@ -0,0 +1,62 @@
import { NextPage, NextPageContext } from 'next'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { get } from '@utils/Query'
import { Server, List } from '@types'
import { serverCategoryListArgumentSchema } from '@utils/Yup'
import NotFound from 'pages/404'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Container = dynamic(() => import('@components/Container'))
const Paginator = dynamic(() => import('@components/Paginator'))
const Category: NextPage<CategoryProps> = ({ data, query }) => {
if(!data || data.data.length === 0 || data.totalPage < Number(query.page)) return <NotFound message={data?.data.length === 0 ? '해당 카테고리에 해당되는 서버가 존재하지 않습니다.' : null} />
return <>
<Hero type='servers' header={`${query.category} 카테고리 서버들`} description={`다양한 "${query.category}" 카테고리의 서버들을 만나보세요.`} />
<Container>
<Advertisement />
<ResponsiveGrid>
{
data.data.map(server => <ServerCard type='list' key={server.id} server={server} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname={`/servers/categories/${query.category}`} />
<Advertisement />
</Container>
</>
}
export const getServerSideProps = async (ctx: Context) => {
let data: List<Server>
if(!ctx.query.page) ctx.query.page = '1'
const validate = await serverCategoryListArgumentSchema.validate(ctx.query).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) data = null
else data = await get.serverList.category.load(JSON.stringify({ page: Number(ctx.query.page), category: ctx.query.category }))
return {
props: {
data,
query: ctx.query
}
}
}
interface CategoryProps {
data: List<Server>
query: URLQuery
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
category: string
page?: string
}
export default Category

View File

@ -0,0 +1,30 @@
import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { NextSeo } from 'next-seo'
import { serverCategories, serverCategoryIcon } from '@utils/Constants'
const Container = dynamic(() => import('@components/Container'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const Tag = dynamic(() => import('@components/Tag'))
const Segment = dynamic(() => import('@components/Segment'))
const Categories:NextPage = () => {
return <Container paddingTop>
<NextSeo title='전체 카테고리' description='한국 디스코드 리스트 서버들의 전체 카테고리입니다.' />
<h1 className='text-2xl font-bold mt-2 mb-5'> </h1>
<Segment className='mb-10'>
<div className='text-center flex flex-wrap mt-1.5'>
{
serverCategories.map(t => <Tag key={t} text={<>
<i className={serverCategoryIcon[t]} /> {t}
</>} href={`/servers/categories/${t}`} dark bigger /> )
}
</div>
</Segment>
<Advertisement />
</Container>
}
export default Categories

58
pages/servers/index.tsx Normal file
View File

@ -0,0 +1,58 @@
import { NextPage } from 'next'
import dynamic from 'next/dynamic'
import { Server, List } from '@types'
import * as Query from '@utils/Query'
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Container = dynamic(() => import('@components/Container'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Paginator = dynamic(() => import('@components/Paginator'))
const Hero = dynamic(() => import('@components/Hero'))
const ServerIndex: NextPage<ServerIndexProps> = ({ votes, trusted }) => {
return <>
<Hero type='servers' />
<Container className='pb-10'>
<Advertisement />
<h1 className='text-3xl font-bold mt-10 mb-2'>
<i className='far fa-heart mr-3 text-pink-600' />
</h1>
<p className='text-base'> !</p>
<ResponsiveGrid>
{
votes.data.map(server=> <ServerCard type='list' key={server.id} server={server} />)
}
</ResponsiveGrid>
<Paginator totalPage={votes.totalPage} currentPage={votes.currentPage} pathname='/servers/list/votes' />
<Advertisement />
<h1 className='text-3xl font-bold mb-2'>
<i className='fa fa-check mr-3 mt-10 text-green-500' />
</h1>
<p className='text-base'> !!</p>
<ResponsiveGrid>
{
trusted.data.slice(0, 4).map(server=> <ServerCard type='list' key={server.id} server={server} />)
}
</ResponsiveGrid>
<Advertisement />
</Container>
</>
}
export const getServerSideProps = async() => {
const votes = await Query.get.serverList.votes.load(1)
const trusted = await Query.get.serverList.trusted.load(1)
return { props: { votes,trusted }}
}
interface ServerIndexProps {
votes: List<Server>
newBots: List<Server>
trusted: List<Server>
}
export default ServerIndex

View File

@ -0,0 +1,63 @@
import { NextPage, NextPageContext } from 'next'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { Server, List } from '@types'
import { get }from '@utils/Query'
import NotFound from '../../404'
import { PageCount } from '@utils/Yup'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Container = dynamic(() => import('@components/Container'))
const Paginator = dynamic(() => import('@components/Paginator'))
const Votes:NextPage<VotesProps> = ({ data }) => {
const router = useRouter()
if(!data || data.data.length === 0 || data.totalPage < Number(router.query.page)) return <NotFound />
return <>
<Hero type='servers' header='하트 랭킹' description='하트를 많이 받은 서버들의 순위입니다!'/>
<section id='list'>
<Container className='pb-10'>
<Advertisement />
<ResponsiveGrid>
{
data.data.map(server => <ServerCard type='list' key={server.id} server={server} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/servers/list/votes' />
<Advertisement />
</Container>
</section>
</>
}
export const getServerSideProps = async (ctx:Context) => {
let data: List<Server>
if(!ctx.query.page) ctx.query.page = '1'
const validate = await PageCount.validate(ctx.query.page).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) data = null
else data = await get.serverList.votes.load(Number(ctx.query.page))
return {
props: {
data
}
}
}
interface VotesProps {
data: List<Server>
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
page: string
}
export default Votes

88
pages/servers/search.tsx Normal file
View File

@ -0,0 +1,88 @@
import { NextPage, NextPageContext } from 'next'
import type { FC } from 'react'
import dynamic from 'next/dynamic'
import { ParsedUrlQuery } from 'querystring'
import { List, Server } from '@types'
import { get } from '@utils/Query'
import { SearchQuerySchema } from '@utils/Yup'
import { KoreanbotsEndPoints } from '@utils/Constants'
const Hero = dynamic(() => import('@components/Hero'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const Container = dynamic(() => import('@components/Container'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Paginator = dynamic(() => import('@components/Paginator'))
const LongButton = dynamic(() => import('@components/LongButton'))
const Redirect = dynamic(() => import('@components/Redirect'))
const SearchComponent: FC<{data: List<Server>, query: URLQuery }> = ({ data, query }) => {
return <div className='py-10'>
{ !data || data.data.length === 0 ? <h1 className='text-3xl font-bold text-center py-20'> .</h1> :
<>
<ResponsiveGrid>
{
data.data.map(el => <ServerCard key={el.id} type='list' server={el as Server} /> )
}
</ResponsiveGrid>
<Paginator totalPage={data.totalPage} currentPage={data.currentPage} pathname='/search' searchParams={query} />
</>
}
</div>
}
const Search:NextPage<SearchProps> = ({ serverData, query }) => {
if(!query?.q) return <Redirect text={false} to='/' />
return <>
<Hero type='servers' header={`"${query.q}" 검색 결과`} description={`'${query.q}' 에 대한 검색 결과입니다.`} />
<Container>
<section id='list'>
<Advertisement />
<h1 className='text-4xl font-bold'></h1>
<SearchComponent data={serverData} query={query} />
<h1 className='text-2xl font-bold py-10'> ?</h1>
<LongButton center href={KoreanbotsEndPoints.URL.searchBot(query.q)}> </LongButton>
<Advertisement />
</section>
</Container>
</>
}
export const getServerSideProps = async(ctx: Context) => {
if(ctx.query.query && !ctx.query.q) ctx.query.q = ctx.query.query
if(!ctx.query?.q) {
ctx.res.statusCode = 301
ctx.res.setHeader('Location', '/')
return { props: {} }
}
if(!ctx.query.page) ctx.query.page = '1'
const validate = await SearchQuerySchema.validate(ctx.query).then(el => el).catch(() => null)
if(!validate || isNaN(Number(ctx.query.page))) return { props: { query: ctx.query } }
else {
return {
props: {
serverData: await get.serverList.search.load(JSON.stringify({ query: ctx.query.q || '', page: ctx.query.page })).then(el => el).catch(() => null),
query: ctx.query
}
}
}
}
interface SearchProps {
serverData?: List<Server>
query: URLQuery
}
interface Context extends NextPageContext {
query: URLQuery
}
interface URLQuery extends ParsedUrlQuery {
q?: string
query?: string
page?: string
}
export default Search

View File

@ -12,7 +12,7 @@ const ToS: NextPage<ToSProps> = ({ content }) => {
return (
<Docs
header='서비스 이용약관'
description='한국 디스코드 리스트의 서비스를 이용하실 때 지켜야하는 약관입니다.'
description='한국 디스코드 리스트의 서비스를 이용하실 때 지켜야하는 약관입니다.'
>
<Markdown text={content} />
</Docs>

View File

@ -6,7 +6,7 @@ import { SnowflakeUtil } from 'discord.js'
import { ParsedUrlQuery } from 'querystring'
import { josa } from 'josa'
import { Bot, User, Theme } from '@types'
import { Bot, User, Theme, Server } from '@types'
import { get } from '@utils/Query'
import { checkUserFlag, parseCookie } from '@utils/Tools'
import { getToken } from '@utils/Csrf'
@ -20,6 +20,7 @@ const Container = dynamic(() => import('@components/Container'))
const DiscordAvatar = dynamic(() => import('@components/DiscordAvatar'))
const Divider = dynamic(() => import('@components/Divider'))
const BotCard = dynamic(() => import('@components/BotCard'))
const ServerCard = dynamic(() => import('@components/ServerCard'))
const ResponsiveGrid = dynamic(() => import('@components/ResponsiveGrid'))
const Tag = dynamic(() => import('@components/Tag'))
const Advertisement = dynamic(() => import('@components/Advertisement'))
@ -63,7 +64,7 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
</div>
<div className='badges flex mb-2 justify-center lg:justify-start'>
{checkUserFlag(data.flags, 'staff') && (
<Tooltip text='한국 디스코드 리스트 스탭입니다.' direction='left'>
<Tooltip text='한국 디스코드 리스트 스탭입니다.' direction='left'>
<div className='pr-5 text-koreanbots-blue text-2xl'>
<i className='fas fa-hammer' />
</div>
@ -102,7 +103,7 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
</div>
</div>
<Divider />
<h2 className='mt-8 text-3xl font-bold'> </h2>
<h2 className='mt-8 pb-4 text-3xl font-bold'> </h2>
{data.bots.length === 0 ? <h2 className='text-xl'> .</h2> :
<ResponsiveGrid>
@ -114,6 +115,16 @@ const Users: NextPage<UserProps> = ({ user, data }) => {
</ResponsiveGrid>
}
<h2 className='py-4 text-3xl font-bold'> </h2>
{data.servers.length === 0 ? <h2 className='text-xl'> .</h2> :
<ResponsiveGrid>
{
(data.servers as Server[]).map((server: Server) => (
<ServerCard type='list' key={server.id} server={server} />
))
}
</ResponsiveGrid>
}
<Advertisement />
</Container>
)

View File

@ -9,13 +9,13 @@ const Verification: NextPage = () => {
return (
<Docs
header='인증'
description='한국 디스코드 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.'
description='한국 디스코드 리스트의 신뢰된 봇은 디스코드 인증보다 더 자세한 기준으로 신뢰성을 주기 위한 제도입니다.'
>
<h1 className='mb-3 text-4xl font-bold'>
<span className='text-koreanbots-blue mr-5'>
<i className='fas fa-award' />
</span> </h1>
<p> .</p>
<p> .</p>
<h2 className='mt-10 text-3xl font-semibold' id='기준'>
</h2>

View File

@ -1,5 +1,5 @@
{
"name": "한국 디스코드 리스트",
"name": "한국 디스코드 리스트",
"short_name": "한디리",
"lang": "ko-KR",
"description": "다양한 국내 디스코드봇들을 확인하고, 초대해보세요!",

View File

@ -1,5 +1,5 @@
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
<ShortName>한국 디스코드 리스트</ShortName>
<ShortName>한국 디스코드 리스트</ShortName>
<Description>국내 디스코드봇을 한 곳에서.</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">https://koreanbots.dev/logo.png</Image>

View File

@ -1,5 +1,8 @@
import { IncomingMessage } from 'http'
import { NextPageContext } from 'next'
import type { GuildFeatures } from 'discord.js'
import type { IncomingMessage } from 'http'
import type { NextPageContext } from 'next'
export type Nullable<T> = T | null
export interface Bot {
id: string
@ -16,7 +19,7 @@ export interface Bot {
shards: number
intro: string
desc: string
category: Category[]
category: BotCategory[]
web: string | null
git: string | null
url: string | null
@ -27,6 +30,41 @@ export interface Bot {
owners: User[] | string[]
}
export interface RawGuild {
id: string
name: string
icon: string
owner: boolean
permissions: number
features: GuildFeatures[]
}
export interface Server {
id: string
name: string
flags: number
state: ServerState
icon: string | null
votes: number
members: number | null
boostTier: number | null
emojis: Emoji[]
intro: string
desc: string
category: ServerCategory[]
invite: string
vanity: string | null
bg: string | null
banner: string | null
owner: User | null
bots: Bot[] | string[]
}
export interface Emoji {
id: string
name: string
url: string
}
export interface User {
id: string
avatar: string
@ -35,6 +73,7 @@ export interface User {
flags: number
github: string
bots: Bot[] | string[]
servers: Server[] | string[]
}
export interface BotSpec {
@ -43,6 +82,41 @@ export interface BotSpec {
token: string
}
export interface ServerMongoData {
data: ServerData
}
export interface ServerData {
name: string
updatedAt: string
icon: string
owner: string
admins: string[]
bots: string[]
memberCount: number
boostTier: number
features: GuildFeatures[]
emojis: ServerEmojiData[]
}
export interface ServerEmojiData {
id: string
name: string
url: string
}
export interface BotMongoData {
serverMetrix: MetrixData[]
voteMetrix: MetrixData[]
inviteMetrix: MetrixData[]
viewMetrix: MetrixData[]
}
export interface MetrixData {
_id: string
count: number
day: string
createdAt: Date
}
export interface DocsData {
name: string
list?: DocsData[]
@ -60,7 +134,7 @@ export enum UserFlags {
export enum BotFlags {
general = 0 << 0,
official = 1 << 0,
holding = 1 << 1,
reserved = 1 << 1,
trusted = 1 << 2,
partnered = 1 << 3,
verified = 1 << 4,
@ -68,6 +142,16 @@ export enum BotFlags {
hackerthon = 1 << 6,
}
export enum ServerFlags {
general = 0 << 0,
official = 1 << 0,
reserved = 1 << 1,
trusted = 1 << 2,
partnered = 1 << 3,
verified = 1 << 4,
discord_partnered = 1 << 5,
}
export enum DiscordUserFlags {
DISCORD_EMPLOYEE = 1 << 0,
DISCORD_PARTNER = 1 << 1,
@ -84,9 +168,9 @@ export enum DiscordUserFlags {
VERIFIED_DEVELOPER = 1 << 17,
}
export interface BotList {
export interface List<T> {
type: ListType
data: Bot[]
data: T[]
currentPage: number
totalPage: number
}
@ -99,7 +183,7 @@ export interface SubmittedBot {
prefix: string
intro: string
desc: string
category: Category[]
category: BotCategory[]
web: string | null
git: string | null
url: string | null
@ -161,7 +245,7 @@ export type Theme = 'dark' | 'light' | 'system'
export type Status = 'online' | 'offline' | 'dnd' | 'idle' | 'streaming' | null
export type BotState = 'ok' | 'reported' | 'blocked' | 'archived' | 'private'
export type ServerState = 'ok' | 'reported' | 'blocked' | 'unreachable'
export type Library =
| 'discord.js'
| 'Eris'
@ -187,7 +271,7 @@ export type Library =
| '기타'
| '비공개'
export type Category =
export type BotCategory =
| '관리'
| '뮤직'
| '전적'
@ -203,6 +287,19 @@ export type Category =
| 'NSFW'
| '검색'
export type ServerCategory =
| '커뮤니티'
| '친목'
| '음악'
| '기술'
| '교육'
// 게임
| '게임'
| '오버워치'
| '리그 오브 레전드'
| '배틀그라운드'
| '마인크래프트'
export type ReportCategory =
| '위법'
| '봇을 이용한 테러'
@ -210,7 +307,7 @@ export type ReportCategory =
| '스팸, 도배, 의미없는 텍스트'
| '폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠'
| '오픈소스 라이선스, 저작권 위반 등 권리 침해'
| 'Discord ToS를 위반하거나 한국 디스코드 리스트 가이드라인 위반'
| 'Discord ToS를 위반하거나 한국 디스코드 리스트 가이드라인 위반'
| '기타'
export type ListType = 'VOTE' | 'TRUSTED' | 'NEW' | 'PARTNERED' | 'CATEGORY' | 'SEARCH'

View File

@ -1,11 +1,12 @@
import { Bot, ImageOptions, KoreanbotsImageOptions } from '@types'
import { Bot, ImageOptions, KoreanbotsImageOptions, Server } from '@types'
import { KeyMap } from 'react-hotkeys'
import { formatNumber, makeImageURL } from './Tools'
// Website META DATA
export const TITLE = '한국 디스코드 리스트'
export const TITLE = '한국 디스코드 리스트'
export const DESCRIPTION = '다양한 국내 디스코드 봇들을 확인하고, 초대해보세요!'
export const THEME_COLOR = '#3366FF'
export const DSKR_BOT_ID = '784618064167698472'
export const VOTE_COOLDOWN = 12 * 60 * 60 * 1000
export const BUG_REPORTERS = ['260303569591205888']
@ -67,7 +68,7 @@ export const library = [
'비공개',
]
export const categories = [
export const botCategories = [
// 상위 카테고리
'관리',
'뮤직',
@ -95,7 +96,7 @@ export const categories = [
'마인크래프트'
]
export const categoryIcon = {
export const botCategoryIcon = {
'관리': 'fas fa-cogs',
'뮤직': 'fas fa-music',
'전적': 'fas fa-puzzle-piece',
@ -119,6 +120,37 @@ export const categoryIcon = {
'마인크래프트': 'fas fa-cubes'
}
export const serverCategories = [
'커뮤니티',
'IT & 과학',
'봇',
'친목',
'음악',
'교육',
'연애',
// 게임
'게임',
'오버워치',
'리그 오브 레전드',
'배틀그라운드',
'마인크래프트'
]
export const serverCategoryIcon = {
'커뮤니티': 'fas fa-comments',
'친목': 'fas fa-user-friends',
'음악': 'fas fa-music',
'IT & 과학': 'fas fa-flask',
'봇': 'fas fa-robot',
'교육': 'fas fa-graduation-cap',
'연애': 'fas fa-hand-holding-heart',
'게임': 'fas fa-gamepad',
'오버워치': 'fas fa-mask',
'리그 오브 레전드': 'fas fa-chess',
'배틀그라운드': 'fas fa-meteor',
'마인크래프트': 'fas fa-cubes'
}
export const reportCats = [
'위법',
'봇을 이용한 테러',
@ -126,7 +158,16 @@ export const reportCats = [
'스팸, 도배, 의미없는 텍스트',
'폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠',
'오픈소스 라이선스, 저작권 위반 등 권리 침해',
'Discord ToS를 위반하거나 한국 디스코드봇 리스트 가이드라인 위반',
'Discord ToS 또는 한국 디스코드 리스트 가이드라인 위반',
'기타',
]
export const serverReportCats = [
'위법',
'괴롭힘, 모욕, 명예훼손',
'폭력, 자해, 테러 옹호하거나 조장하는 컨텐츠',
'저작권 위반 등 권리 침해',
'Discord ToS 또는 한국 디스코드 리스트 가이드라인 위반',
'기타',
]
@ -145,6 +186,7 @@ export const MessageColor = {
export const BASE_URLs = {
api: 'https://discord.com/api',
invite: 'https://discord.gg',
cdn: 'https://cdn.discordapp.com',
camo: 'https://camo.koreanbots.dev'
}
@ -174,10 +216,33 @@ export const BotBadgeType = (data: Bot) => {
}
}
export const ServerBadgeType = (data: Server) => {
return {
members: {
label: '멤버수',
status: !data.members ? 'N/A' : formatNumber(data.members),
color: '7289DA'
},
votes: {
label: '하트',
status: `${formatNumber(data.votes)}`,
color: 'ef4444'
},
boost: {
label: '부스트',
status: `${!data.boostTier ? 0 : data.boostTier}레벨`,
color: 'fe73fa'
}
}
}
export const DiscordEnpoints = {
Token: BASE_URLs.api + '/oauth2/token',
Me: BASE_URLs.api + '/v8/users/@me',
InviteApplication: (id: string, perms: { [perm: string]: boolean }, scope: string, redirect?: string): string => `${BASE_URLs.api}/oauth2/authorize?client_id=${id ? id.split(' ')[0] : 'CLIENT_ID'}&permissions=${Object.keys(perms).filter(el => perms[el]).map(el => Number(el)).reduce((prev, curr) => prev | curr, 0)}&scope=${scope ? encodeURI(scope) : 'bot'}${redirect ? `&redirect_uri=${encodeURIComponent(redirect)}` : ''}`,
Me: BASE_URLs.api + '/v9/users/@me',
Guilds: BASE_URLs.api + '/v9/users/@me/guilds',
InviteApplication: (id: string, perms: { [perm: string]: boolean }, scope: string, redirect?: string, guild_id?: string): string => `${BASE_URLs.api}/oauth2/authorize?client_id=${id ? id.split(' ')[0] : 'CLIENT_ID'}&permissions=${Object.keys(perms).filter(el => perms[el]).map(el => Number(el)).reduce((prev, curr) => prev | curr, 0)}&scope=${scope ? encodeURI(scope) : 'bot'}${redirect ? `&redirect_uri=${encodeURIComponent(redirect)}` : ''}${guild_id ? `&guild_id=${guild_id}` : ''}`,
ServerInvite: (code: string): string => `${BASE_URLs.invite}/${code}`,
CDN: class CDN {
static root = BASE_URLs.cdn
static emoji (id: string, options:ImageOptions={}) { return makeImageURL(`${this.root}/emojis/${id}`, options) }
@ -191,25 +256,36 @@ export const KoreanbotsEndPoints = {
OG: class {
static root = 'https://og.kbots.link'
static origin = 'https://koreanbots.dev'
static bot(id: string, name: string, bio: string, tags: string[], stats: string[]) {
static generate(id: string, name: string, bio: string, tags: string[], stats: string[], type: 'bot' | 'server') {
const u = new URL(this.root)
u.pathname = name
u.searchParams.append('image', this.origin + KoreanbotsEndPoints.CDN.avatar(id, { format: 'webp', size: 256 }))
u.pathname = name + '.png'
u.searchParams.append('image', this.origin + ( type === 'bot' ? KoreanbotsEndPoints.CDN.avatar(id, { format: 'webp', size: 256 }) : KoreanbotsEndPoints.CDN.icon(id, { format: 'webp', size: 256 }) ))
u.searchParams.append('bio', bio)
u.searchParams.append('type', type)
tags.map(t => u.searchParams.append('tags', t))
stats.map(s => u.searchParams.append('stats', s))
return u.href
}
static bot(id: string, name: string, bio: string, tags: string[], stats: string[]) {
return this.generate(id, name, bio, tags, stats, 'bot')
}
static server(id: string, name: string, bio: string, tags: string[], stats: string[]) {
return this.generate(id, name, bio, tags, stats, 'server')
}
},
CDN: class {
static root = '/api/image'
static avatar (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`${this.root}/discord/avatars/${id}`, options) }
static icon (id: string, options: KoreanbotsImageOptions) { return makeImageURL(`${this.root}/discord/icons/${id}`, options) }
},
URL: class {
static root = process.env.KOREANBOTS_URL || 'https://koreanbots.dev'
static bot (id: string) { return `${this.root}/bots/${id}` }
static server (id: string) { return `${this.root}/servers/${id}` }
static user (id: string) { return `${this.root}/users/${id}` }
static submittedBot(id: string, date: number) { return `${this.root}/pendingBots/${id}/${date}` }
static searchBot(query: string) { return `${this.root}/bots/search?q=${encodeURIComponent(query)}` }
static searchServer(query: string) { return `${this.root}/servers/search?q=${encodeURIComponent(query)}` }
},
baseAPI: '/api/v2',
login: '/api/auth/discord',
@ -240,10 +316,10 @@ export const git = { 'github.com': { icon: 'github', text: 'GitHub' }, 'gitlab.
export const KoreanbotsDiscord = 'https://discord.gg/JEh53MQ'
export const ThemeColors = [{ name: '파랑', rgb: 'rgb(51, 102, 255)', hex: '#3366FF', color: 'koreanbots-blue' }, { name: '하양', rgb: 'rgb(251, 251, 251)', hex: '#FBFBFB', color: 'little-white' }, { name: '검정', rgb: 'rgb(27, 30, 35)', hex: '#1B1E23', color: 'very-black' }, { name: '보라', rgb: 'rgb(114, 137, 218)', hex: '#7289DA', color: 'discord-blurple' } ]
export const KoreanbotsEmoji = [{
name: '한국 디스코드 리스트',
name: '한국 디스코드 리스트',
short_names: ['koreanbots', 'kbots', 'dbkr'],
emoticons: [],
keywords: ['koreanbots', '한국 디스코드 리스트', '한디리', 'kbots'],
keywords: ['koreanbots', '한국 디스코드 리스트', '한디리', 'kbots'],
imageUrl: '/logo.png'
},
{
@ -289,6 +365,7 @@ export const ErrorText = {
export const ErrorMessage = ['지나가던 고양이가 선을 밟았어요..', '무언가 잘못되었어요..!', '이게 아닌데...', '어쩜 이렇게 오류가 또 나는건지?']
export const ServerIntroList = ['한국어를 배울 수 있는 최고의 공간입니다!', '김치의 다양한 요리법을 소개하는 서버입니다.', '좋아하는 노래를 들을 수 있는 곳 입니다.', '게임을 함께 할 사람을 찾을 수 있습니다.']
export const BotSubmissionDenyReasonPresetsName = {
MISSING_VERIFY: '개발자 확인 불가',
OFFLINE: '봇 오프라인',

29
utils/Mongo.ts Normal file
View File

@ -0,0 +1,29 @@
import mongoose from 'mongoose'
import { getYYMMDD } from './Tools'
mongoose.connect(`mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@${process.env.MONGO_HOST || 'localhost'}/${process.env.MONGO_DATABASE}`)
const db = mongoose.connection
db.on('error', console.error.bind(console, 'connection error:'))
db.once('open', () =>
console.log('[DB] Mongo Connected')
)
const metrix = { day: { type: String, default: getYYMMDD, unique: true }, createdAt: { type: Date, default: Date.now }, count: Number }
const botSchema = new mongoose.Schema({
_id: String,
serverMetrix: [ metrix ],
viewMetrix: [ metrix ],
voteMetrix: [ { ...metrix, increasement: { type: Number, default: 1 } } ],
inviteMetrix: [ metrix ],
comments: [ { author: String, date: { type: Date, defualt: Date.now }, comment: String, rating: Number, upvotes: [ String ], downvotes: [ String ] } ],
})
const serverSchema = new mongoose.Schema({
_id: String,
data: {}
})
export const Bots = mongoose.models.bots || mongoose.model('bots', botSchema)
export const Servers = mongoose.models.servers || mongoose.model('servers', serverSchema)

View File

@ -3,19 +3,20 @@ import { TLRU } from 'tlru'
import DataLoader from 'dataloader'
import { User as DiscordUser } from 'discord.js'
import { Bot, User, ListType, BotList, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot } from '@types'
import { categories, imageSafeHost, SpecialEndPoints, VOTE_COOLDOWN } from './Constants'
import { Bot, Server, User, ListType, List, TokenRegister, BotFlags, DiscordUserFlags, SubmittedBot, DiscordTokenInfo, ServerData, ServerFlags, RawGuild, Nullable } from '@types'
import { botCategories, DiscordEnpoints, imageSafeHost, serverCategories, SpecialEndPoints, VOTE_COOLDOWN } from './Constants'
import knex from './Knex'
import { Bots, Servers } from './Mongo'
import { DiscordBot, getMainGuild } from './DiscordBot'
import { sign, verify } from './Jwt'
import { camoUrl, formData, serialize } from './Tools'
import { AddBotSubmit, ManageBot } from './Yup'
import { camoUrl, formData, getYYMMDD, serialize } from './Tools'
import { AddBotSubmit, AddServerSubmit, ManageBot, ManageServer } from './Yup'
import { markdownImage } from './Regex'
export const imageRateLimit = new TLRU<unknown, number>({ maxAgeMs: 60000 })
async function getBot(id: string, owners=true):Promise<Bot> {
async function getBot(id: string, topLevel=true):Promise<Bot> {
const res = await knex('bots')
.select([
'id',
@ -57,7 +58,7 @@ async function getBot(id: string, owners=true):Promise<Bot> {
res[0].status = discordBot.presence?.activities?.find(r => r.type === 'STREAMING') ? 'streaming' : discordBot.presence?.status || null
delete res[0].trusted
delete res[0].partnered
if (owners) {
if (topLevel) {
res[0].owners = await Promise.all(
res[0].owners.map(async (u: string) => await get._rawUser.load(u))
)
@ -71,27 +72,103 @@ async function getBot(id: string, owners=true):Promise<Bot> {
return res[0] ?? null
}
async function getUser(id: string, bots = true):Promise<User> {
async function getServer(id: string, topLevel=true): Promise<Server> {
const res = await knex('servers')
.select([
'id',
'name',
'flags',
'intro',
'desc',
'votes',
'owners',
'category',
'invite',
'state',
'vanity',
'bg',
'banner',
'flags'
])
.where({ id })
.orWhereRaw(`(flags & ${ServerFlags.trusted}) and vanity=?`, [id])
.orWhereRaw(`(flags & ${ServerFlags.partnered}) and vanity=?`, [id])
if (res[0]) {
const data = await getServerData(res[0].id)
if(!data || (+new Date() - +new Date(data.updatedAt)) > 3 * 60 * 1000) res[0].state = 'unreachable'
else {
res[0].flags = res[0].flags | (data.features.includes('PARTNERED') && ServerFlags.discord_partnered) | (data.features.includes('VERIFIED') && ServerFlags.verified)
if(res[0].owners !== JSON.stringify([data.owner, ...data.admins]) || res[0].name !== data.name)
await knex('servers').update({ name: data.name, owners: JSON.stringify([data.owner, ...data.admins]) })
.where({ id: res[0].id })
}
delete res[0].owners
// console.log(data)
res[0].icon = data?.icon || null
res[0].members = data?.memberCount || null
res[0].emojis = data?.emojis || []
res[0].category = JSON.parse(res[0].category)
res[0].boostTier = data?.boostTier ?? null
if(topLevel) {
res[0].owner = await get._rawUser.load(data?.owner || '') || null
res[0].bots = (await Promise.all(data?.bots.slice(0, 3).map(el => get._rawBot.load(el)) || [])).filter(el => el) || null
} else {
res[0].owner = data?.owner || null
res[0].bots = data?.bots || null
}
}
return res[0] ?? null
}
async function fetchServerOwners(id: string): Promise<User[]|null> {
const data = await getServerData(id)
return data ? [ await get._rawUser.load(data.owner), ...(await Promise.all(data.admins.map(el => get._rawUser.load(el)))) ].filter(el => el) : null
}
async function getServerData(id: string): Promise<ServerData|null> {
return serialize((await Servers.findById(id))?.data || null)
}
async function getUser(id: string, topLevel = true):Promise<User> {
const res = await knex('users')
.select(['id', 'flags', 'github'])
.where({ id })
if (res[0]) {
const owned = await knex('bots')
const ownedBots = await knex('bots')
.select(['id'])
.where('owners', 'like', `%${id}%`)
const ownedServer = await knex('servers')
.select(['id'])
.where('owners', 'like', `%${id}%`)
const discordUser = await get.discord.user.load(id)
res[0].tag = discordUser?.discriminator || '0000'
res[0].username = discordUser?.username || 'Unknown User'
if (bots) {
res[0].bots = await Promise.all(owned.map(async b => await get._rawBot.load(b.id)))
res[0].bots = res[0].bots.filter((el: Bot | null) => el).map((row: User) => ({ ...row }))
if (topLevel) {
res[0].bots = (await Promise.all(ownedBots.map(async b => await get._rawBot.load(b.id)))).filter((el: Bot | null) => el).map(row => ({ ...row }))
res[0].servers = (await Promise.all(ownedServer.map(async b => await get._rawServer.load(b.id)))).filter((el: Server | null) => el).map(row => ({ ...row }))
}
else {
res[0].bots = ownedBots.map(el => el.id)
res[0].servers = ownedServer.map(el => el.id)
}
else res[0].bots = owned.map(el => el.id)
}
return res[0] || null
}
async function getBotList(type: ListType, page = 1, query?: string):Promise<BotList> {
async function getUserGuilds(id: string): Promise<Nullable<RawGuild[]>> {
const token = await fetchUserDiscordToken(id)
if(!token) return null
const guilds = await fetch(DiscordEnpoints.Guilds, {
headers: {
Authorization: `Bearer ${token.access_token}`,
}
}).then(r=> r.json())
if(!Array.isArray(guilds)) return null
return guilds
}
async function getBotList(type: ListType, page = 1, query?: string):Promise<List<Bot>> {
let res: { id: string }[]
let count:string|number
if (type === 'VOTE') {
@ -142,7 +219,7 @@ async function getBotList(type: ListType, page = 1, query?: string):Promise<BotL
.select(['id'])
} else if (type === 'CATEGORY') {
if (!query) throw new Error('쿼리가 누락되었습니다.')
if (!categories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
if (!botCategories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
count = (
await knex('bots')
.where('category', 'like', `%${decodeURI(query)}%`).andWhereNot({ state: 'blocked' })
@ -167,6 +244,83 @@ async function getBotList(type: ListType, page = 1, query?: string):Promise<BotL
return { type, data: (await Promise.all(res.map(async el => await getBot(el.id)))).map(r=> ({...r})), currentPage: page, totalPage: Math.ceil(Number(count) / 16) }
}
async function getServerList(type: ListType, page = 1, query?: string):Promise<List<Server>> {
let res: { id: string }[]
let count:string|number
if (type === 'VOTE') {
count = (await knex('servers').whereNot({ state: 'blocked' }).count())[0]['count(*)']
res = await knex('servers')
.orderBy('votes', 'desc')
.limit(16)
.offset(((page ? Number(page) : 1) - 1) * 16)
.select(['id'])
.whereNot({ state: 'blocked' })
} else if (type === 'TRUSTED') {
count = (
await knex('servers')
.whereRaw(`flags & ${ServerFlags.trusted}`)
.count()
.whereNot({ state: 'blocked' })
)[0]['count(*)']
res = await knex('servers').whereNot({ state: 'blocked' })
.whereRaw(`flags & ${ServerFlags.trusted}`)
.orderByRaw('RAND()')
.limit(16)
.offset(((page ? Number(page) : 1) - 1) * 16)
.select(['id'])
.whereNot({ state: 'blocked' })
} else if (type === 'NEW') {
count = (
await knex('servers').whereNot({ state: 'blocked' })
.count()
)[0]['count(*)']
res = await knex('servers')
.orderBy('date', 'desc')
.limit(16)
.offset(((page ? Number(page) : 1) - 1) * 16)
.select(['id'])
.whereNot({ state: 'blocked' })
} else if (type === 'PARTNERED') {
count = (
await knex('servers')
.whereRaw(`flags & ${ServerFlags.partnered}`)
.andWhereNot({ state: 'blocked' })
.count()
)[0]['count(*)']
res = await knex('servers')
.whereRaw(`flags & ${ServerFlags.partnered}`)
.andWhereNot({ state: 'blocked' })
.orderByRaw('RAND()')
.limit(16)
.offset(((page ? Number(page) : 1) - 1) * 16)
.select(['id'])
} else if (type === 'CATEGORY') {
if (!query) throw new Error('쿼리가 누락되었습니다.')
if (!serverCategories.includes(query)) throw new Error('알 수 없는 카테고리입니다.')
count = (
await knex('servers')
.where('category', 'like', `%${decodeURI(query)}%`)
.andWhereNot({ state: 'blocked' })
.count()
)[0]['count(*)']
res = await knex('servers')
.where('category', 'like', `%${decodeURI(query)}%`)
.andWhereNot({ state: 'blocked' })
.orderBy('votes', 'desc')
.limit(16)
.offset(((page ? Number(page) : 1) - 1) * 16)
.select(['id'])
} else if (type === 'SEARCH') {
if (!query) throw new Error('쿼리가 누락되었습니다.')
count = (await knex.raw('SELECT count(*) FROM servers WHERE MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode)', [decodeURI(query)]))[0][0]['count(*)']
res = (await knex.raw('SELECT id, votes, MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode) as relevance FROM servers WHERE MATCH(`name`, `intro`, `desc`) AGAINST(? in boolean mode) ORDER BY relevance DESC, votes DESC LIMIT 16 OFFSET ?', [decodeURI(query), decodeURI(query), ((page ? Number(page) : 1) - 1) * 16]))[0]
} else {
count = 1
res = []
}
return { type, data: (await Promise.all(res.map(async el => await getServer(el.id)))).map(r=> ({...r})), currentPage: page, totalPage: Math.ceil(Number(count) / 16) }
}
async function getBotSubmit(id: string, date: number): Promise<SubmittedBot> {
const res = await knex('submitted').select(['id', 'date', 'category', 'lib', 'prefix', 'intro', 'desc', 'url', 'web', 'git', 'discord', 'state', 'owners', 'reason']).where({ id, date })
if(res.length === 0) return null
@ -191,28 +345,44 @@ async function getBotSubmits(id: string): Promise<SubmittedBot[]> {
* @param botID
* @returns Timestamp
*/
async function getBotVote(userID: string, botID: string): Promise<number|null> {
async function getVote(userID: string, targetID: string, type: 'bot' | 'server'): Promise<number|null> {
const user = await knex('users').select(['votes']).where({ id: userID })
if(user.length === 0) return null
const data = JSON.parse(user[0].votes)
return data[botID] || 0
return data[`${type}:${targetID}`] || 0
}
async function voteBot(userID: string, botID: string): Promise<number|boolean> {
const user = await knex('users').select(['votes']).where({ id: userID })
const key = `bot:${botID}`
if(user.length === 0) return null
const date = +new Date()
const data = JSON.parse(user[0].votes)
const lastDate = data[botID] || 0
const lastDate = data[key] || 0
if(date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
data[botID] = date
data[key] = date
await knex('bots').where({ id: botID }).increment('votes', 1)
await knex('users').where({ id: userID }).update({ votes: JSON.stringify(data) })
const record = await Bots.updateOne({ _id: botID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
if(record.n === 0) await Bots.findByIdAndUpdate(botID, { $push: { voteMetrix: { count: (await knex('bots').where({ id: botID }))[0].votes } } }, { upsert: true })
return true
}
async function voteServer(userID: string, serverID: string): Promise<number|boolean> {
const user = await knex('users').select(['votes']).where({ id: userID })
const key = `server:${serverID}`
if(user.length === 0) return null
const date = +new Date()
const data = JSON.parse(user[0].votes)
const lastDate = data[key] || 0
if(date - lastDate < VOTE_COOLDOWN) return VOTE_COOLDOWN - (date - lastDate)
data[key] = date
await knex('servers').where({ id: serverID }).increment('votes', 1)
await knex('users').where({ id: userID }).update({ votes: JSON.stringify(data) })
// const record = await Servers.updateOne({ _id: serverID, 'voteMetrix.day': getYYMMDD() }, { $inc: { 'voteMetrix.$.increasement': 1, 'voteMetrix.$.count': 1 } })
// if(record.n === 0) await Servers.findByIdAndUpdate(serverID, { $push: { voteMetrix: { count: (await knex('servers').where({ id: serverID }))[0].votes } } }, { upsert: true })
return true
}
/**
* @returns 1 - Has pending Bots
* @returns 2 - Already submitted ID
@ -251,23 +421,62 @@ async function submitBot(id: string, data: AddBotSubmit):Promise<1|2|3|4|Submitt
return await getBotSubmit(botId, date)
}
/**
* @returns 1 - Server already exists
* @returns 2 - Bot not invited
* @returns 3 - Not owner
* @returns 4 - Invalid invite code
*/
async function submitServer(userID: string, id: string, data: AddServerSubmit): Promise<1|2|3|4|boolean> {
const server = await get.server.load(id)
if(server) return 1
const serverData = await get.serverData(id)
if(!serverData) return 2
if(serverData.owner !== userID && !serverData.admins.includes(userID)) return 3
const inviteData = await DiscordBot.fetchInvite(data.invite).catch(() => null)
if(!inviteData || inviteData.guild.id !== id) return 4
await knex('servers').insert({
id: id,
name: serverData.name,
owners: JSON.stringify([ serverData.owner, ...serverData.admins ]),
intro: data.intro,
desc: data.desc,
category: JSON.stringify(data.category),
invite: data.invite,
token: sign({ id }),
})
get.server.clear(id)
return true
}
async function getBotSpec(id: string, userID: string) {
const res = await knex('bots').select(['id', 'token', 'webhook']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
if(!res[0]) return null
return serialize(res[0])
}
async function getServerSpec(id: string, userID: string): Promise<{ id: string, token: string }> {
const res = await knex('servers').select(['id', 'token']).where({ id }).andWhere('owners', 'like', `%${userID}%`)
if(!res[0]) return null
return serialize(res[0])
}
async function deleteBot(id: string): Promise<boolean> {
const bot = await knex('bots').where({ id }).del()
get.bot.clear(id)
return !!bot
}
async function updateBot(id: string, data: ManageBot) {
async function deleteServer(id: string): Promise<boolean> {
const server = await knex('servers').where({ id }).del()
return !!server
}
async function updateBot(id: string, data: ManageBot): Promise<number> {
const res = await knex('bots').where({ id })
if(res.length === 0 || res[0].state !== 'ok') return 0
if(res.length === 0) return 0
await knex('bots').update({
id,
prefix: data.prefix,
lib: data.library,
web: data.website,
@ -282,6 +491,19 @@ async function updateBot(id: string, data: ManageBot) {
return 1
}
async function updatedServer(id: string, data: ManageServer) {
const res = await knex('servers').where({ id })
if(res.length === 0) return 0
await knex('servers').update({
invite: data.invite,
category: JSON.stringify(data.category),
intro: data.intro,
desc: data.desc
}).where({ id })
return 1
}
/**
* @returns 1 - Limit of 100k servers
* @returns 2 - Limit of 10M servers
@ -293,6 +515,10 @@ async function updateServer(id: string, servers: number, shards: number) {
else if(bot.servers < 1000000 && servers >= 1000000) return 2
if(bot.shards < 200 && shards >= 200) return 3
await knex('bots').update({ servers: servers === undefined ? bot.servers : servers, shards: shards === undefined ? bot.shards : shards }).where({ id })
if(servers) {
await Bots.findByIdAndUpdate(id, { $pull: { serverMetrix: { day: getYYMMDD() } } }, { upsert: true })
await Bots.findByIdAndUpdate(id, { $push: { serverMetrix: { count: servers } } })
}
return
}
@ -314,6 +540,13 @@ async function resetBotToken(id: string, beforeToken: string) {
return token
}
async function resetServerToken(id: string, beforeToken: string) {
const token = sign({ id })
const server = await knex('servers').update({ token }).where({ id, token: beforeToken })
if(server !== 1) return null
return token
}
async function Github(id: string, github: string) {
const user = await knex('users').where({ github }).whereNot({ id })
if(github && user.length !== 0) return 0
@ -369,6 +602,37 @@ async function BotAuthorization(token: string):Promise<string|false> {
else return bot[0].id
}
async function ServerAuthorization(token: string): Promise<string|false> {
const tokenInfo = verify(token ?? '')
const server = await knex('servers').select(['id']).where({ id: tokenInfo?.id ?? '', token: token ?? '' })
if(server.length === 0) return false
else return server[0].id
}
async function fetchUserDiscordToken(id: string): Promise<DiscordTokenInfo> {
const res = await knex('users').select(['discord']).where({ id })
let discord = verify(res[0]?.discord ?? '')
if(!discord) return null
if(Date.now() > (discord.iat + discord.expires_in) * 1000) {
const token: DiscordTokenInfo = await fetch(DiscordEnpoints.Token, {
method: 'POST',
body: formData({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
refresh_token: discord.refresh_token,
grant_type: 'refresh_token'
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}).then(r=> r.json())
if (token.error) return null
await knex('users').update({ discord: sign({ access_token: token.access_token, expires_in: token.expires_in, refresh_token: token.refresh_token }) }).where({ id })
discord = token
}
return discord
}
async function addRequest(ip: string, map: TLRU<unknown, number>) {
if(!map.has(ip)) map.set(ip, 0)
map.set(ip, map.get(ip) + 1)
@ -408,6 +672,22 @@ async function approveBotSubmission(id: string, date: number) {
return true
}
export function safeImageHost(text: string) {
return text?.replace(markdownImage, (matches: string, alt: string|undefined, link: string|undefined, description: string|undefined): string => {
try {
const url = new URL(link)
return `![${alt || description || ''}](${imageSafeHost.find(el => url.host.endsWith(el)) ? link : camoUrl(link) })`
} catch {
return matches
}
}) || null
}
async function viewBot(id: string) {
const record = await Bots.updateOne({ _id: id, 'viewMetrix.day': getYYMMDD() }, { $inc: { 'viewMetrix.$.count': 1 } })
if(record.n === 0) await Bots.findByIdAndUpdate(id, { $push: { viewMetrix: { count: 0 } } }, { upsert: true })
}
export const get = {
discord: {
user: new DataLoader(
@ -425,15 +705,16 @@ export const get = {
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
botDescSafe: async (id: string) => {
const bot = await get.bot.load(id)
return bot?.desc.replace(markdownImage, (matches: string, alt: string|undefined, link: string|undefined, description: string|undefined): string => {
try {
const url = new URL(link)
return `![${alt || description || ''}](${imageSafeHost.find(el => url.host.endsWith(el)) ? link : camoUrl(link) })`
} catch {
return matches
}
}) || null
return safeImageHost(bot?.desc)
},
server: new DataLoader(
async (ids: string[]) =>
(await Promise.all(ids.map(async (id: string) => await getServer(id)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 5000, maxAgeMs: 60000 }) }),
_rawServer: new DataLoader(
async (ids: string[]) =>
(await Promise.all(ids.map(async (id: string) => await getServer(id, false)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 5000, maxAgeMs: 60000 }) }),
user: new DataLoader(
async (ids: string[]) =>
(await Promise.all(ids.map(async (el: string) => await getUser(el)))).map(row => serialize(row))
@ -442,6 +723,10 @@ export const get = {
async (ids: string[]) =>
(await Promise.all(ids.map(async (el: string) => await getUser(el, false)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
userGuilds: new DataLoader(
async (ids: string[]) =>
(await Promise.all(ids.map(async (el: string) => await getUserGuilds(el)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
botSubmits: new DataLoader(
async (ids: string[]) =>
(await Promise.all(ids.map(async (el: string) => await getBotSubmits(el)))).map(row => serialize(row))
@ -454,6 +739,7 @@ export const get = {
}))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 60000 }) }),
botSpec: getBotSpec,
serverSpec: getServerSpec,
list: {
category: new DataLoader(
async (key: string[]) =>
@ -483,37 +769,81 @@ export const get = {
(await Promise.all(pages.map(async (page: number) => await getBotList('TRUSTED', page)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 3600000 }) }),
},
serverList: {
category: new DataLoader(
async (key: string[]) =>
(await Promise.all(key.map(async (k: string) => {
const json = JSON.parse(k)
return await getServerList('CATEGORY', json.page, json.category)
}))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
search: new DataLoader(
async (key: string[]) =>
(await Promise.all(key.map(async (k: string) => {
const json = JSON.parse(k)
const res = await getServerList('SEARCH', json.page, json.query)
return { ...res, totalPage: Number(res.totalPage), currentPage: Number(res.currentPage) }
}))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
votes: new DataLoader(
async (pages: number[]) =>
(await Promise.all(pages.map(async (page: number) => await getServerList('VOTE', page)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 500000 }) }),
new: new DataLoader(
async (pages: number[]) =>
(await Promise.all(pages.map(async (page: number) => await getServerList('NEW', page)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 1800000 }) }),
trusted: new DataLoader(
async (pages: number[]) =>
(await Promise.all(pages.map(async (page: number) => await getServerList('TRUSTED', page)))).map(row => serialize(row))
, { cacheMap: new TLRU({ maxStoreSize: 50, maxAgeMs: 3600000 }) }),
},
images: {
user: new DataLoader(
async (urls: string[]) =>
(await Promise.all(urls.map(async (url: string) => await getImage(url))))
, { cacheMap: new TLRU({ maxStoreSize: 500, maxAgeMs: 3600000 }) }),
server: new DataLoader(
async (urls: string[]) =>
(await Promise.all(urls.map(async (url: string) => await getImage(url))))
, { cacheMap: new TLRU({ maxStoreSize: 500, maxAgeMs: 3600000 }) }),
},
botVote: getBotVote,
serverData: getServerData,
botVote: async (botID: string, targetID: string) => await getVote(botID, targetID, 'bot'),
vote: getVote,
Authorization,
BotAuthorization,
botSubmitList: getBotSubmitList
ServerAuthorization,
botSubmitList: getBotSubmitList,
serverOwners: fetchServerOwners
}
export const update = {
assignToken,
updateBotApplication,
resetBotToken,
resetServerToken,
updateServer,
Github,
bot: updateBot,
server: updatedServer,
botOwners: updateOwner,
denyBotSubmission,
approveBotSubmission
approveBotSubmission,
fetchUserDiscordToken
}
export const put = {
voteBot,
submitBot
voteServer,
submitBot,
submitServer,
viewBot
}
export const remove = {
bot: deleteBot
bot: deleteBot,
server: deleteServer
}
export const ratelimit = {

View File

@ -5,9 +5,10 @@ import { Readable } from 'stream'
import cookie from 'cookie'
import * as difflib from 'difflib'
import { BotFlags, ImageOptions, UserFlags } from '@types'
import { BotFlags, ImageOptions, MetrixData, ServerFlags, UserFlags } from '@types'
import Logger from '@utils/Logger'
import { BASE_URLs, KoreanbotsEndPoints, Oauth } from '@utils/Constants'
import Day from './Day'
export function handlePWA(): boolean {
let displayMode = 'browser'
@ -48,6 +49,10 @@ export function checkBotFlag(base: number, required: number | keyof typeof BotFl
return checkFlag(base, typeof required === 'number' ? required : BotFlags[required])
}
export function checkServerFlag(base: number, required: number | keyof typeof ServerFlags):boolean {
return checkFlag(base, typeof required === 'number' ? required : ServerFlags[required])
}
export function makeImageURL(root:string, { format='png', size=256 }:ImageOptions):string {
return `${root}.${format}?size=${size}`
}
@ -56,9 +61,14 @@ export function makeBotURL({ id, vanity, flags=0 }: { flags?: number, vanity?:st
return `/bots/${(checkBotFlag(flags, 'trusted') || checkBotFlag(flags, 'partnered')) && vanity ? vanity : id}`
}
export function makeServerURL({ id, vanity, flags=0 }: { flags?: number, vanity?:string, id: string }): string {
return `/servers/${(checkServerFlag(flags, 'trusted') || checkServerFlag(flags, 'partnered')) && vanity ? vanity : id}`
}
export function makeUserURL({ id }: { id: string }): string {
return `/users/${id}`
}
export function serialize<T>(data: T): T {
return JSON.parse(JSON.stringify(data))
}
@ -194,4 +204,15 @@ export function parseDockerhubTag(imageTag: string) {
return imageTag?.split('/').pop().split(':').pop()
}
export function getYYMMDD(): string {
return (new Date()).toISOString().slice(0, 10).split('-').join('')
}
export function convertMetrixToGraph(data: MetrixData[], keyname?: string) {
return data.map(el=> ({
x: Day(el.day, 'YYMMDD').toDate(),
y: el[keyname] || el.count
}))
}
export * from './ShowdownExtensions'

View File

@ -1,7 +1,7 @@
import * as Yup from 'yup'
import YupKorean from 'yup-locales-ko'
import { ListType } from '@types'
import { categories, library, reportCats } from '@utils/Constants'
import { botCategories, library, reportCats, serverCategories } from '@utils/Constants'
import { HTTPProtocol, ID, Prefix, Url, Vanity } from '@utils/Regex'
Yup.setLocale(YupKorean)
@ -79,6 +79,35 @@ interface WidgetOptions {
type widgetType = 'votes' | 'servers' | 'status'
type widgetExt = 'svg'
export const ServerWidgetOptionsSchema: Yup.SchemaOf<ServerWidgetOptions> = Yup.object({
id: Yup.string().required(),
ext: Yup.mixed<widgetExt>()
.oneOf(['svg'])
.required(),
type: Yup.mixed<serverWidgetType>()
.oneOf(['votes', 'members', 'boost'])
.required(),
scale: Yup.number()
.positive()
.min(0.5)
.max(3)
.required(),
style: Yup.mixed<'flat' | 'classic'>()
.oneOf(['flat', 'classic'])
.default('flat'),
icon: Yup.boolean().default(true),
})
interface ServerWidgetOptions {
id: string
ext: widgetExt
type: serverWidgetType
scale: number
style: 'flat' | 'classic'
icon: boolean
}
type serverWidgetType = 'votes' | 'members' | 'boost'
export const PageCount = Yup.number()
.integer()
.positive()
@ -91,7 +120,7 @@ export const OauthCallbackSchema: Yup.SchemaOf<OauthCallback> = Yup.object({
export const botCategoryListArgumentSchema: Yup.SchemaOf<botCategoryListArgument> = Yup.object({
page: PageCount,
category: Yup.mixed()
.oneOf(categories)
.oneOf(botCategories)
.required(),
})
@ -100,6 +129,18 @@ interface botCategoryListArgument {
category: string
}
export const serverCategoryListArgumentSchema: Yup.SchemaOf<serverCategoryListArgument> = Yup.object({
page: PageCount,
category: Yup.mixed()
.oneOf(serverCategories)
.required(),
})
interface serverCategoryListArgument {
page: number
category: string
}
interface OauthCallback {
code: string
}
@ -116,11 +157,13 @@ export const SearchQuerySchema: Yup.SchemaOf<SearchQuery> = Yup.object({
.notRequired()
.default(1)
.label('페이지'),
priority: Yup.mixed().oneOf(['bot', 'server']).notRequired()
})
interface SearchQuery {
q: string
page: number
page: number,
priority?: 'bot' | 'server'
}
export const AddBotSubmitSchema: Yup.SchemaOf<AddBotSubmit> = Yup.object({
@ -158,7 +201,7 @@ export const AddBotSubmitSchema: Yup.SchemaOf<AddBotSubmit> = Yup.object({
.min(2, '지원 디스코드는 최소 2자여야합니다.')
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.')
.nullable(),
category: Yup.array(Yup.string().oneOf(categories))
category: Yup.array(Yup.string().oneOf(botCategories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
@ -187,7 +230,41 @@ export interface AddBotSubmit {
intro: string
desc: string
_csrf: string
_captcha?: string
_captcha: string
}
export const AddServerSubmitSchema: Yup.SchemaOf<AddServerSubmit> = Yup.object({
agree: Yup.boolean()
.oneOf([true], '상단의 체크박스를 클릭해주세요.')
.required('상단의 체크박스를 클릭해주세요.'),
invite: Yup.string()
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
.min(2, '초대코드는 최소 2자여야합니다.')
.max(32, '초대코드는 최대 32자까지만 가능합니다.')
.required('초대코드는 필수 항목입니다.'),
category: Yup.array(Yup.string().oneOf(serverCategories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
intro: Yup.string()
.min(2, '서버 소개는 최소 2자여야합니다.')
.max(60, '서버 소개는 최대 60자여야합니다.')
.required('서버 소개는 필수 항목입니다.'),
desc: Yup.string()
.min(100, '서버 설명은 최소 100자여야합니다.')
.max(1500, '서버 설명은 최대 1500자여야합니다.')
.required('서버 설명은 필수 항목입니다.'),
_csrf: Yup.string().required(),
_captcha: Yup.string().required()
})
export interface AddServerSubmit {
agree: boolean
invite: string
category: string[]
intro: string
desc: string
_csrf: string
_captcha: string
}
export const BotStatUpdateSchema: Yup.SchemaOf<BotStatUpdate> = Yup.object({
@ -249,7 +326,7 @@ export const ManageBotSchema: Yup.SchemaOf<ManageBot> = Yup.object({
.min(2, '지원 디스코드는 최소 2자여야합니다.')
.max(32, '지원 디스코드는 최대 32자까지만 가능합니다.')
.nullable(),
category: Yup.array(Yup.string().oneOf(categories))
category: Yup.array(Yup.string().oneOf(botCategories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
@ -277,6 +354,35 @@ export interface ManageBot {
_csrf: string
}
export const ManageServerSchema = Yup.object({
invite: Yup.string()
.matches(Vanity, '디스코드 초대코드 형식을 지켜주세요.')
.min(2, '초대코드는 최소 2자여야합니다.')
.max(32, '초대코드는 최대 32자까지만 가능합니다.')
.required('초대코드는 필수 항목입니다.'),
category: Yup.array(Yup.string().oneOf(serverCategories))
.min(1, '최소 한 개의 카테고리를 선택해주세요.')
.unique('카테고리는 중복될 수 없습니다.')
.required('카테고리는 필수 항목입니다.'),
intro: Yup.string()
.min(2, '서버 소개는 최소 2자여야합니다.')
.max(60, '서버 소개는 최대 60자여야합니다.')
.required('서버 소개는 필수 항목입니다.'),
desc: Yup.string()
.min(100, '서버 설명은 최소 100자여야합니다.')
.max(1500, '서버 설명은 최대 1500자여야합니다.')
.required('서버 설명은 필수 항목입니다.'),
_csrf: Yup.string().required(),
})
export interface ManageServer {
invite: string
category: string[]
intro: string
desc: string
_csrf: string
}
export const CsrfCaptchaSchema: Yup.SchemaOf<CsrfCaptcha> = Yup.object({
_csrf: Yup.string().required(),
_captcha: Yup.string().required()
@ -301,12 +407,14 @@ export interface DeveloperBot {
_csrf: string
}
export const ResetBotTokenSchema: Yup.SchemaOf<ResetBotToken> = Yup.object({
export const ResetTokenSchema: Yup.SchemaOf<ResetToken> = Yup.object({
token: Yup.string().required(),
_csrf: Yup.string().required(),
})
export interface ResetBotToken {
export const ResetBotTokenSchema = ResetTokenSchema
export interface ResetToken {
token: string
_csrf: string
}

164
yarn.lock
View File

@ -1713,6 +1713,13 @@
"@types/connect" "*"
"@types/node" "*"
"@types/bson@*":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@types/bson/-/bson-4.0.4.tgz#79d2d26e81070044db2a1a8b2cc2f673c840e1e5"
integrity sha512-awqorHvQS0DqxkHQ/FxcPX9E+H7Du51Qw/2F+5TBMSaE3G0hm+8D3eXJ6MAzFw75nE8V7xF0QvzUSdxIjJb/GA==
dependencies:
"@types/node" "*"
"@types/connect@*":
version "3.4.35"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@ -1842,6 +1849,14 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
"@types/mongodb@^3.5.27":
version "3.6.20"
resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.6.20.tgz#b7c5c580644f6364002b649af1c06c3c0454e1d2"
integrity sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==
dependencies:
"@types/bson" "*"
"@types/node" "*"
"@types/node-emoji@1.8.1":
version "1.8.1"
resolved "https://registry.yarnpkg.com/@types/node-emoji/-/node-emoji-1.8.1.tgz#689cb74fdf6e84309bcafce93a135dfecd01de3f"
@ -1860,6 +1875,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.2.tgz#655432817f83b51ac869c2d51dd8305fb8342e16"
integrity sha512-jJs9ErFLP403I+hMLGnqDRWT0RYKSvArxuBVh2veudHV7ifEC1WAmjJADacZ7mRbA2nWgHtn8xyECMAot0SkAw==
"@types/node@14.x || 15.x":
version "15.14.4"
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.14.4.tgz#aaf18436ef67f24676d92b8bbe0f5f41b08db3e8"
integrity sha512-yblJrsfCxdxYDUa2fM5sP93ZLk5xL3/+3MJei+YtsNbIdY75ePy2AiCfpq+onepzax+8/Yv+OD/fLNleWpCzVg==
"@types/node@16.4.3":
version "16.4.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.4.3.tgz#c01c1a215721f6dec71b47d88b4687463601ba48"
@ -2665,6 +2685,19 @@ bindings@^1.5.0:
dependencies:
file-uri-to-path "1.0.0"
bl@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5"
integrity sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==
dependencies:
readable-stream "^2.3.5"
safe-buffer "^5.1.1"
bluebird@3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
integrity sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
@ -2791,6 +2824,11 @@ bser@2.1.1:
dependencies:
node-int64 "^0.4.0"
bson@^1.1.4:
version "1.1.6"
resolved "https://registry.yarnpkg.com/bson/-/bson-1.1.6.tgz#fb819be9a60cd677e0853aee4ca712a785d6618a"
integrity sha512-EvVNVeGo4tHxwi8L6bPj3y3itEvStdwvvlojVxxbyYfoaxJ6keLgrTuKdyfEAszFK+H3olzBuafE0yoh0D1gdg==
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@ -3461,6 +3499,13 @@ debug@2, debug@^2.6.9:
dependencies:
ms "2.0.0"
debug@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
dependencies:
ms "2.0.0"
debug@4, debug@4.3.2, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
version "4.3.2"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
@ -3557,6 +3602,11 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
denque@^1.4.1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
depd@^1.1.2, depd@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@ -5819,6 +5869,11 @@ jws@^3.2.2:
jwa "^1.4.1"
safe-buffer "^5.0.1"
kareem@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.3.2.tgz#78c4508894985b8d38a0dc15e1a8e11078f2ca93"
integrity sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ==
kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
@ -6181,6 +6236,11 @@ memoize-one@^5.0.0:
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
memory-pager@^1.0.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@ -6332,6 +6392,60 @@ module-details-from-path@^1.0.3:
resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b"
integrity sha1-EUyUlnPiqKNenTV4hSeqN7Z52is=
mongodb@3.6.10:
version "3.6.10"
resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.6.10.tgz#f10e990113c86b195c8af0599b9b3a90748b6ee4"
integrity sha512-fvIBQBF7KwCJnDZUnFFy4WqEFP8ibdXeFANnylW19+vOwdjOAvqIzPdsNCEMT6VKTHnYu4K64AWRih0mkFms6Q==
dependencies:
bl "^2.2.1"
bson "^1.1.4"
denque "^1.4.1"
optional-require "^1.0.3"
safe-buffer "^5.1.2"
optionalDependencies:
saslprep "^1.0.0"
mongoose-legacy-pluralize@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"
integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ==
mongoose@^5.13.4:
version "5.13.4"
resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.13.4.tgz#060a24fa17dd80a6c0bfad128d34616331477d8d"
integrity sha512-D1yVHAOa+G8iQZsC/nNzZe+CI1FCYu6Qk384s1vSyaSfKCu/alKeyL78BA73SsxeRKT9zmswSIueLbGBURjrKg==
dependencies:
"@types/mongodb" "^3.5.27"
"@types/node" "14.x || 15.x"
bson "^1.1.4"
kareem "2.3.2"
mongodb "3.6.10"
mongoose-legacy-pluralize "1.0.2"
mpath "0.8.3"
mquery "3.2.5"
ms "2.1.2"
optional-require "1.0.x"
regexp-clone "1.0.0"
safe-buffer "5.2.1"
sift "13.5.2"
sliced "1.0.1"
mpath@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.8.3.tgz#828ac0d187f7f42674839d74921970979abbdd8f"
integrity sha512-eb9rRvhDltXVNL6Fxd2zM9D4vKBxjVVQNLNijlj7uoXUy19zNDsIif5zR+pWmPCWNKwAtqyo4JveQm4nfD5+eA==
mquery@3.2.5:
version "3.2.5"
resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.2.5.tgz#8f2305632e4bb197f68f60c0cffa21aaf4060c51"
integrity sha512-VjOKHHgU84wij7IUoZzFRU07IAxd5kWJaDmyUzQlbjHjyoeK5TNeeo8ZsFDtTYnSgpW6n/nMNIHvE3u8Lbrf4A==
dependencies:
bluebird "3.5.1"
debug "3.1.0"
regexp-clone "^1.0.0"
safe-buffer "5.1.2"
sliced "1.0.1"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -6744,6 +6858,18 @@ opentracing@>=0.12.1:
resolved "https://registry.yarnpkg.com/opentracing/-/opentracing-0.14.5.tgz#891fa92cd90a24e64f99bc964370227310926c85"
integrity sha512-XLKtEfHxqrWyF1fzxznsv78w3csW41ucHnjiKnfzZLD5FN8UBDZZL1i4q0FR29zjxXhm+2Hop+5Vr/b8tKIvEg==
optional-require@1.0.x:
version "1.0.3"
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.0.3.tgz#275b8e9df1dc6a17ad155369c2422a440f89cb07"
integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA==
optional-require@^1.0.3:
version "1.1.1"
resolved "https://registry.yarnpkg.com/optional-require/-/optional-require-1.1.1.tgz#214314d1f9fbdd6f9e28fb12c9a3b4422ef93fdc"
integrity sha512-EnUe33GTAltyZlIsQ2l93KzBC9zi8BsxLvKP3wxALOsz/YIakVojyuZsv5PFFk8y8e6r+SbaPIsNmyPoSK0OHw==
dependencies:
require-at "^1.0.6"
optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
@ -7907,7 +8033,7 @@ readable-stream@1.1.x:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@2.3.7, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6:
readable-stream@2.3.7, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@ -7992,6 +8118,11 @@ regenerator-transform@^0.14.2:
dependencies:
"@babel/runtime" "^7.8.4"
regexp-clone@1.0.0, regexp-clone@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-1.0.0.tgz#222db967623277056260b992626354a04ce9bf63"
integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw==
regexp.prototype.flags@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26"
@ -8034,6 +8165,11 @@ regjsparser@^0.6.4:
dependencies:
jsesc "~0.5.0"
require-at@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/require-at/-/require-at-1.0.6.tgz#9eb7e3c5e00727f5a4744070a7f560d4de4f6e6a"
integrity sha512-7i1auJbMUrXEAZCOQ0VNJgmcT2VOKPRl2YGJwgpHpC9CE91Mv4/4UYIUm4chGJaI381ZDq1JUicFii64Hapd8g==
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -8173,7 +8309,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@ -8196,6 +8332,13 @@ sanitize-html@2.4.0:
parse-srcset "^1.0.2"
postcss "^8.0.2"
saslprep@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/saslprep/-/saslprep-1.0.3.tgz#4c02f946b56cf54297e347ba1093e7acac4cf226"
integrity sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==
dependencies:
sparse-bitfield "^3.0.3"
saxes@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d"
@ -8331,6 +8474,11 @@ side-channel@^1.0.4:
get-intrinsic "^1.0.2"
object-inspect "^1.9.0"
sift@13.5.2:
version "13.5.2"
resolved "https://registry.yarnpkg.com/sift/-/sift-13.5.2.tgz#24a715e13c617b086166cd04917d204a591c9da6"
integrity sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA==
signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
@ -8362,6 +8510,11 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
sliced@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41"
integrity sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E=
slide@~1.1.3:
version "1.1.6"
resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
@ -8447,6 +8600,13 @@ sourcemap-codec@^1.4.4:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
sparse-bitfield@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11"
integrity sha1-/0rm5oZWBWuks+eSqzM004JzyhE=
dependencies:
memory-pager "^1.0.2"
spdx-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-1.0.0.tgz#2c55f117362078d7409e6d7b08ce70a857cd3ed7"