feat(api): url generator for shortener

This commit is contained in:
AlphaNecron 2021-10-15 13:34:03 +07:00
parent 02c40e0568
commit 2eba13ef48
17 changed files with 114 additions and 186 deletions

View File

@ -23,4 +23,5 @@ route = '/go'
raw_route = '/r' raw_route = '/r'
length = 6 length = 6
directory = './uploads' directory = './uploads'
max_size = 104857600
blacklisted = ['exe'] blacklisted = ['exe']

View File

@ -1,6 +1,6 @@
{ {
"name": "void", "name": "void",
"version": "0.5.0", "version": "0.5.1",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=14" "node": ">=14"

View File

@ -91,7 +91,8 @@
raw_route = '/r' # Route to serve raw contents raw_route = '/r' # Route to serve raw contents
length = 6 # Slug length length = 6 # Slug length
directory = './uploads' # The directory where images are stored directory = './uploads' # The directory where images are stored
blacklisted = ['exe'] # Blacklisted extensions max_size = 104857600 # Max upload size (users only), in bytes
blacklisted = ['exe'] # Blacklisted file extensions (users only)
``` ```
### Features ### Features

View File

@ -1,98 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { mkdir, readFile, stat } from 'fs/promises';
import { createServer } from 'http';
import next from 'next';
import { extname, join } from 'path';
import deployDb from '../scripts/deployDb';
import prismaRun from '../scripts/prismaRun';
import { error, info } from '../src/lib/logger';
import mimetypes from '../src/lib/mimetypes';
import validate from '../src/lib/validateConfig';
import readConfig from '../src/lib/configReader';
import start from '../twilight/twilight';
import { name, version } from '../package.json';
info('SERVER', `Starting ${name}@${version}`);
const dev = process.env.NODE_ENV === 'development';
(async () => {
try {
const config = await validate(readConfig());
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
if (data.match(/Following migrations? have not yet been applied/)) {
info('DB', 'Some migrations are not applied, applying them now...');
await deployDb(config);
info('DB', 'Finished applying migrations');
await prismaRun(config.core.database_url, ['db', 'seed'])
}
process.env.DATABASE_URL = config.core.database_url;
if (config.bot.enabled) {
if (!config.bot.token) error('BOT', 'Token is not specified');
else start(config);
}
await stat('./.next');
await mkdir(config.uploader.directory, { recursive: true });
const app = next({
dir: '.',
dev,
quiet: dev
});
await app.prepare();
const handle = app.getRequestHandler();
const prisma = new PrismaClient();
const srv = createServer(async (req, res) => {
if (req.url.startsWith(config.uploader.raw_route)) {
const parts = req.url.split('/');
if (!parts[2] || parts[2] === '') return;
let data;
try {
data = await readFile(join(process.cwd(), config.uploader.directory, parts[2]));
}
catch {
app.render404(req, res);
}
if (!data) {
app.render404(req, res);
} else {
let file = await prisma.file.findFirst({
where: {
fileName: parts[2],
}
});
if (file) {
res.setHeader('Content-Type', file.mimetype);
} else {
const mimetype = mimetypes[extname(parts[2])] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype);
}
res.setHeader('Content-Length', data.byteLength);
res.end(data);
}
} else {
handle(req, res);
}
if (!(req.url.startsWith('/_next') || req.url.startsWith('/__nextjs'))) {
res.statusCode === 200 ? info('ROUTER', `${res.statusCode} ${req.url}`) : error('URL', `${res.statusCode} ${req.url}`);
}
});
srv.on('error', (e) => {
error('SERVER', e);
process.exit(1);
});
srv.on('listening', async () => {
info('SERVER', `Listening on ${config.core.host}:${config.core.port}`);
});
srv.listen(config.core.port, config.core.host);
} catch (e) {
if (e.message && e.message.startsWith('Could not find a production')) {
console.log(e.message);
error('WEB', 'There is no production build - run yarn build');
} else if (e.code && e.code === 'ENOENT') {
if (e.path === './.next') error('WEB', 'There is no production build - run yarn build');
} else {
error('SERVER', e);
process.exit(1);
}
}
})();

View File

@ -1,4 +1,4 @@
import { Button, ButtonGroup, Heading, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Select, Switch } from '@chakra-ui/react'; import { Button, ButtonGroup, Tab, TabList, TabPanels, TabPanel, Tabs, Heading, Input, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Select, Switch } from '@chakra-ui/react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Download, X } from 'react-feather'; import { Download, X } from 'react-feather';
@ -6,8 +6,10 @@ export default function ShareXDialog({ open, onClose, token }) {
const ref = React.useRef(); const ref = React.useRef();
const [name, setName] = useState('Void'); const [name, setName] = useState('Void');
const [generator, setGenerator] = useState('random'); const [generator, setGenerator] = useState('random');
const [tab, setTab] = useState(0);
const [usePassword, setUsePassword] = useState(false);
const [preserveFileName, setPreserveFileName] = useState(false); const [preserveFileName, setPreserveFileName] = useState(false);
const generateConfig = shortener => { const downloadConfig = () => {
const apiUrl = `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api`; const apiUrl = `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api`;
const uploaderConfig = { const uploaderConfig = {
Version: '13.2.1', Version: '13.2.1',
@ -34,7 +36,9 @@ export default function ShareXDialog({ open, onClose, token }) {
RequestMethod: 'POST', RequestMethod: 'POST',
RequestURL: `${apiUrl}/shorten`, RequestURL: `${apiUrl}/shorten`,
Headers: { Headers: {
Authorization: token Authorization: token,
Generator: generator,
...(usePassword && { Password: '$prompt:Password$' })
}, },
Body: 'FormURLEncoded', Body: 'FormURLEncoded',
Arguments: { Arguments: {
@ -44,8 +48,8 @@ export default function ShareXDialog({ open, onClose, token }) {
ErrorMessage: '$json:error$' ErrorMessage: '$json:error$'
}; };
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(shortener ? shortenerConfig : uploaderConfig, null, '\t'))); a.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(tab === 0 ? uploaderConfig : shortenerConfig, null, '\t')));
a.setAttribute('download', `${name.replaceAll(' ', '_')}.sxcu`); a.setAttribute('download', `${name.replaceAll(' ', '_')}_${tab === 0 ? 'Uploader' : 'Shortener'}.sxcu`);
a.click(); a.click();
}; };
return ( return (
@ -55,36 +59,66 @@ export default function ShareXDialog({ open, onClose, token }) {
isOpen={open} isOpen={open}
scrollBehavior='inside' scrollBehavior='inside'
> >
<ModalOverlay/> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>ShareX config generator</ModalHeader> <ModalHeader>ShareX config generator</ModalHeader>
<ModalCloseButton/> <ModalCloseButton />
<ModalBody> <ModalBody>
<Heading mb={1} size='sm'>Config name</Heading> <Tabs index={tab} onChange={index => setTab(index)} size='sm'>
<Input <TabList>
value={name} <Tab>Uploader</Tab>
onChange={n => setName(n.target.value)} <Tab>Shortener</Tab>
placeholder='Void' </TabList>
size='sm' <TabPanels>
/> <TabPanel>
<Heading mt={2} mb={1} size='sm'>URL generator</Heading> <Heading mb={1} size='sm'>Config name</Heading>
<Select <Input
value={generator} value={name}
onChange={g => setGenerator(g.target.value)} onChange={n => setName(n.target.value)}
size='sm' placeholder='Void'
> size='sm'
<option value='random'>Random</option> />
<option value='zws'>Invisible</option> <Heading mt={2} mb={1} size='sm'>URL generator</Heading>
<option value='emoji'>Emoji</option> <Select
</Select> value={generator}
<Heading mt={2} mb={1} size='sm'>Preserve file name</Heading> onChange={g => setGenerator(g.target.value)}
<Switch isChecked={preserveFileName} onChange={p => setPreserveFileName(p.target.checked)}/> size='sm'
>
<option value='random'>Random</option>
<option value='zws'>Invisible</option>
<option value='emoji'>Emoji</option>
</Select>
<Heading mt={2} mb={1} size='sm'>Preserve file name</Heading>
<Switch isChecked={preserveFileName} onChange={p => setPreserveFileName(p.target.checked)} />
</TabPanel>
<TabPanel>
<Heading mb={1} size='sm'>Config name</Heading>
<Input
value={name}
onChange={n => setName(n.target.value)}
placeholder='Void'
size='sm'
/>
<Heading mt={2} mb={1} size='sm'>URL generator</Heading>
<Select
value={generator}
onChange={g => setGenerator(g.target.value)}
size='sm'
>
<option value='random'>Random</option>
<option value='zws'>Invisible</option>
<option value='emoji'>Emoji</option>
</Select>
<Heading mt={2} mb={1} size='sm'>Use password</Heading>
<Switch isChecked={usePassword} onChange={p => setUsePassword(p.target.checked)} />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<ButtonGroup size='sm'> <ButtonGroup size='sm'>
<Button onClick={onClose} leftIcon={<X size={16}/>}>Cancel</Button> <Button onClick={onClose} leftIcon={<X size={16} />}>Cancel</Button>
<Button colorScheme='purple' leftIcon={<Download size={16}/>} onClick={() => generateConfig(true)}>Shortener</Button> <Button colorScheme='purple' leftIcon={<Download size={16} />} onClick={() => downloadConfig()} ref={ref}>Download</Button>
<Button colorScheme='purple' leftIcon={<Download size={16}/>} onClick={() => generateConfig(false)} ref={ref}>Uploader</Button>
</ButtonGroup> </ButtonGroup>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>

View File

@ -36,13 +36,13 @@ export default function Upload() {
body body
}); });
const json = await res.json(); const json = await res.json();
if (res.ok && !json.error) { if (!json.error) {
showToast('success', 'File uploaded', json.url); showToast('success', 'File uploaded', json.url);
if (copy(json.url)) if (copy(json.url))
showToast('info', 'Copied the URL to your clipboard'); showToast('info', 'Copied the URL to your clipboard');
else
showToast('error', 'Couldn\'t upload the file', json.error);
} }
else
showToast('error', 'Couldn\'t upload the file', json.error);
} }
catch (error) { catch (error) {
showToast('error', 'Error while uploading the file', error.message); showToast('error', 'Error while uploading the file', error.message);

View File

@ -1,4 +1,4 @@
import { Button, ButtonGroup, FormControl, FormLabel, HStack, IconButton, Input, Link, Popover, PopoverArrow, PopoverBody, PopoverCloseButton, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, Skeleton, Table, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast } from '@chakra-ui/react'; import { Button, ButtonGroup, FormControl, FormLabel, HStack, Select, IconButton, Input, Link, Popover, PopoverArrow, PopoverBody, PopoverCloseButton, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, Skeleton, Table, Tbody, Td, Th, Thead, Tr, useDisclosure, useToast } from '@chakra-ui/react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
@ -16,6 +16,7 @@ export default function URLs() {
const schema = yup.object({ const schema = yup.object({
destination: yup.string().matches(/((?:(?:http?|ftp)[s]*:\/\/)?[a-z0-9-%\/\&=?\.]+\.[a-z]{2,4}\/?([^\s<>\#%"\,\{\}\\|\\\^\[\]`]+)?)/i).min(3).required(), destination: yup.string().matches(/((?:(?:http?|ftp)[s]*:\/\/)?[a-z0-9-%\/\&=?\.]+\.[a-z]{2,4}\/?([^\s<>\#%"\,\{\}\\|\\\^\[\]`]+)?)/i).min(3).required(),
vanity: yup.string(), vanity: yup.string(),
generator: yup.string(),
urlPassword: yup.string() urlPassword: yup.string()
}); });
const handleDelete = async u => { const handleDelete = async u => {
@ -45,7 +46,8 @@ export default function URLs() {
const data = { const data = {
destination: schemify(values.destination.trim()), destination: schemify(values.destination.trim()),
vanity: values.vanity.trim(), vanity: values.vanity.trim(),
password: values.urlPassword.trim() password: values.urlPassword.trim(),
generator: values.generator
}; };
setBusy(true); setBusy(true);
const res = await useFetch('/api/shorten', 'POST', data); const res = await useFetch('/api/shorten', 'POST', data);
@ -82,7 +84,7 @@ export default function URLs() {
</PopoverHeader> </PopoverHeader>
<PopoverArrow/> <PopoverArrow/>
<PopoverCloseButton/> <PopoverCloseButton/>
<Formik validationSchema={schema} initialValues={{ destination: '', vanity: '', urlPassword: '' }} onSubmit={(values, actions) => { handleSubmit(values, actions); }}> <Formik validationSchema={schema} initialValues={{ destination: '', vanity: '', generator: 'random', urlPassword: '' }} onSubmit={(values, actions) => { handleSubmit(values, actions); }}>
{props => ( {props => (
<Form> <Form>
<PopoverBody> <PopoverBody>
@ -90,7 +92,7 @@ export default function URLs() {
{({ field, form }) => ( {({ field, form }) => (
<FormControl isInvalid={form.errors.destination && form.touched.destination} isRequired> <FormControl isInvalid={form.errors.destination && form.touched.destination} isRequired>
<FormLabel htmlFor='destination'>Destination</FormLabel> <FormLabel htmlFor='destination'>Destination</FormLabel>
<Input {...field} size='sm' id='destination' mb={4} placeholder='Destination'/> <Input {...field} size='sm' id='destination' mb={2} placeholder='Destination'/>
</FormControl> </FormControl>
)} )}
</Field> </Field>
@ -98,7 +100,19 @@ export default function URLs() {
{({ field }) => ( {({ field }) => (
<FormControl> <FormControl>
<FormLabel htmlFor='vanity'>Vanity URL</FormLabel> <FormLabel htmlFor='vanity'>Vanity URL</FormLabel>
<Input {...field} size='sm' id='vanity' mb={4} placeholder='Leave blank for random'/> <Input {...field} size='sm' id='vanity' mb={2} placeholder='Leave blank for random'/>
</FormControl>
)}
</Field>
<Field name='generator'>
{({ field }) => (
<FormControl>
<FormLabel htmlFor='generator'>URL generator</FormLabel>
<Select {...field} size='sm' id='generator' mb={2}>
<option value='random'>Random</option>
<option value='zws'>Invisible</option>
<option value='emoji'>Emoji</option>
</Select>
</FormControl> </FormControl>
)} )}
</Field> </Field>
@ -106,7 +120,7 @@ export default function URLs() {
{({ field }) => ( {({ field }) => (
<FormControl> <FormControl>
<FormLabel htmlFor='urlPassword'>Password</FormLabel> <FormLabel htmlFor='urlPassword'>Password</FormLabel>
<Input {...field} size='sm' id='urlPassword' mb={4} placeholder='Password'/> <Input {...field} size='sm' id='urlPassword' mb={2} placeholder='Password'/>
</FormControl> </FormControl>
)} )}
</Field> </Field>

View File

@ -15,6 +15,7 @@ const envValues = [
e('UPLOADER_RAW_ROUTE', 'string', (c, v) => c.uploader.raw_route = v), e('UPLOADER_RAW_ROUTE', 'string', (c, v) => c.uploader.raw_route = v),
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v), e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v), e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
e('UPLOADER_MAX_SIZE', 'number', (c, v) => c.uploader.max_size = v),
e('UPLOADER_BLACKLISTED', 'array', (c, v) => v ? c.uploader.blacklisted = v : c.uploader.blacklisted = []), e('UPLOADER_BLACKLISTED', 'array', (c, v) => v ? c.uploader.blacklisted = v : c.uploader.blacklisted = []),
e('BOT_ENABLED', 'boolean', (c, v) => c.bot.enabled = v), e('BOT_ENABLED', 'boolean', (c, v) => c.bot.enabled = v),

View File

@ -26,6 +26,7 @@ export interface Uploader {
raw_route: string; raw_route: string;
length: number; length: number;
directory: string; directory: string;
max_size: number;
blacklisted: string[]; blacklisted: string[];
} }

View File

@ -6,7 +6,7 @@ const validator = yup.object({
secret: yup.string().min(8).required(), secret: yup.string().min(8).required(),
host: yup.string().default('0.0.0.0'), host: yup.string().default('0.0.0.0'),
port: yup.number().default(3000), port: yup.number().default(3000),
database_url: yup.string().required(), database_url: yup.string().required()
}).required(), }).required(),
bot: yup.object({ bot: yup.object({
enabled: yup.bool().default(false), enabled: yup.bool().default(false),
@ -26,7 +26,8 @@ const validator = yup.object({
raw_route: yup.string().required(), raw_route: yup.string().required(),
length: yup.number().default(6), length: yup.number().default(6),
directory: yup.string().required(), directory: yup.string().required(),
blacklisted: yup.array().default([]), max_size: yup.number().default(104857600),
blacklisted: yup.array().default([])
}).required(), }).required(),
}); });

View File

@ -115,6 +115,7 @@ export const getServerSideProps: GetServerSideProps = async context => {
const slug = context.params.id[0]; const slug = context.params.id[0];
if (slug === config.shortener.route.split('/').pop()) { if (slug === config.shortener.route.split('/').pop()) {
const short = context.params.id[1]; const short = context.params.id[1];
if ((short ?? '').trim() === '') return { notFound: true };
const url = await prisma.url.findFirst({ const url = await prisma.url.findFirst({
where: { where: {
short short

View File

@ -4,7 +4,7 @@ import { verifyPassword } from 'lib/utils';
import { NextApiReq, NextApiRes, withVoid } from 'middleware/withVoid'; import { NextApiReq, NextApiRes, withVoid } from 'middleware/withVoid';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method !== 'POST') return res.status(405).end(); if (req.method !== 'POST') return res.forbid('Invalid method');
const { username, password } = req.body as { username: string, password: string }; const { username, password } = req.body as { username: string, password: string };
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {

View File

@ -1,5 +1,5 @@
import { default as config } from 'lib/config'; import config from 'lib/config';
import generate from 'lib/generators'; import generate, { emoji, zws } from 'lib/generators';
import { info } from 'lib/logger'; import { info } from 'lib/logger';
import { NextApiReq, NextApiRes, withVoid } from 'lib/middleware/withVoid'; import { NextApiReq, NextApiRes, withVoid } from 'lib/middleware/withVoid';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
@ -25,11 +25,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}); });
if (existing) return res.error('Vanity is already taken'); if (existing) return res.error('Vanity is already taken');
} }
const rand = generate(config.shortener.length); const generator = req.headers.generator || req.body.generator;
const rand = generator === 'zws' ? zws(config.shortener.length) : generator === 'emoji' ? emoji(config.shortener.length) : generate(config.shortener.length);
if (req.body.password) var password = await hashPassword(req.body.password); if (req.body.password) var password = await hashPassword(req.body.password);
const url = await prisma.url.create({ const url = await prisma.url.create({
data: { data: {
short: req.body.vanity ? req.body.vanity : rand, short: req.body.vanity || rand,
destination: req.body.destination, destination: req.body.destination,
userId: user.id, userId: user.id,
password password

View File

@ -23,23 +23,10 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbid('Unauthorized'); if (!user) return res.forbid('Unauthorized');
if (!req.file) return res.error('No file specified'); if (!req.file) return res.error('No file specified');
const ext = req.file.originalname.includes('.') ? req.file.originalname.split('.').pop() : req.file.originalname; const ext = req.file.originalname.includes('.') ? req.file.originalname.split('.').pop() : req.file.originalname;
if (cfg.uploader.blacklisted.includes(ext)) return res.error('Blacklisted extension received: ' + ext); if (cfg.uploader.blacklisted.includes(ext) && !user.isAdmin) return res.error(`Blacklisted extension received: ${ext}`);
if (req.file.size > cfg.uploader.max_size && !user.isAdmin) return res.error('The file is too big');
const rand = generate(cfg.uploader.length); const rand = generate(cfg.uploader.length);
let slug; const slug = req.headers.generator === 'zws' ? zws(cfg.uploader.length) : req.headers.generator === 'emoji' ? emoji(cfg.uploader.length) : rand;
switch (req.headers.generator) {
case 'zws': {
slug = zws(cfg.uploader.length);
break;
}
case 'emoji': {
slug = emoji(cfg.uploader.length);
break;
}
default: {
slug = rand;
break;
}
}
const deletionToken = generate(15); const deletionToken = generate(15);
function getMimetype(current, ext) { function getMimetype(current, ext) {
if (current === 'application/octet-stream') { if (current === 'application/octet-stream') {

View File

@ -28,10 +28,9 @@ const shorten = {
id: 1 id: 1
} }
}); });
const rand = generate(config.shortener.length);
const url = await prisma.url.create({ const url = await prisma.url.create({
data: { data: {
short: vanity ? vanity : rand, short: vanity ?? generate(config.shortener.length),
destination: schemify(dest), destination: schemify(dest),
userId: config.bot.default_uid, userId: config.bot.default_uid,
} }

View File

@ -29,21 +29,7 @@ const upload = {
const fileName = url.parse(args[0]).pathname; const fileName = url.parse(args[0]).pathname;
const ext = extname(fileName); const ext = extname(fileName);
const rand = generate(config.uploader.length); const rand = generate(config.uploader.length);
let slug; const slug = args[1] === 'zws' ? zws(config.uploader.length) : args[1] === 'emoji' ? emoji(config.uploader.length) : rand;
switch (args[1] ?? 'normal') {
case 'zws': {
slug = zws(config.uploader.length);
break;
}
case 'emoji': {
slug = emoji(config.uploader.length);
break;
}
default: {
slug = rand;
break;
}
}
const deletionToken = generate(15); const deletionToken = generate(15);
function getMimetype(current, ext) { function getMimetype(current, ext) {
if (current === 'application/octet-stream') { if (current === 'application/octet-stream') {

View File

@ -1,5 +1,5 @@
import Discord, { Message, MessageEmbed } from 'discord.js'; import Discord, { Message, MessageEmbed } from 'discord.js';
import { readdir } from 'fs'; import { readdirSync } from 'fs';
import { error, info } from '../src/lib/logger'; import { error, info } from '../src/lib/logger';
import { Logger } from './utils/logger'; import { Logger } from './utils/logger';
@ -16,14 +16,13 @@ client.once('ready', () => {
avatarUrl = client.user.displayAvatarURL(); avatarUrl = client.user.displayAvatarURL();
global.logger = new Logger(client); global.logger = new Logger(client);
global.logger.log('Twilight is ready'); global.logger.log('Twilight is ready');
readdir(`${__dirname}/commands`, (err, files) => { readdirSync(`${__dirname}/commands`)
if(err) error('BOT', err.message); .map(file => file.toString())
files.forEach(file => { .filter(file => file.endsWith('.ts'))
if (file.toString().includes('.ts')) { .forEach(file => {
import(`${__dirname}/commands/${file.toString()}`).then(command => commands.push(command.default)); import(`${__dirname}/commands/${file.toString()}`).then(command => commands.push(command.default));
info('COMMAND', `Loaded command: ${file.toString().split('.').shift()}`);} info('COMMAND', `Loaded command: ${file.toString().split('.').shift()}`);
}); });
});
}); });
client.on('message', (msg: Message) => { client.on('message', (msg: Message) => {