diff --git a/config.example.toml b/config.example.toml index bd01c83..6496fde 100644 --- a/config.example.toml +++ b/config.example.toml @@ -23,4 +23,5 @@ route = '/go' raw_route = '/r' length = 6 directory = './uploads' +max_size = 104857600 blacklisted = ['exe'] \ No newline at end of file diff --git a/package.json b/package.json index 94720b2..600da17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void", - "version": "0.5.0", + "version": "0.5.1", "private": true, "engines": { "node": ">=14" diff --git a/readme.md b/readme.md index ea531b9..75a9939 100644 --- a/readme.md +++ b/readme.md @@ -91,7 +91,8 @@ raw_route = '/r' # Route to serve raw contents length = 6 # Slug length 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 diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 6738a62..0000000 --- a/server/index.ts +++ /dev/null @@ -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); - } - } -})(); diff --git a/src/components/ShareXDialog.tsx b/src/components/ShareXDialog.tsx index c9de2af..9e74b16 100644 --- a/src/components/ShareXDialog.tsx +++ b/src/components/ShareXDialog.tsx @@ -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 { Download, X } from 'react-feather'; @@ -6,8 +6,10 @@ export default function ShareXDialog({ open, onClose, token }) { const ref = React.useRef(); const [name, setName] = useState('Void'); const [generator, setGenerator] = useState('random'); + const [tab, setTab] = useState(0); + const [usePassword, setUsePassword] = 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 uploaderConfig = { Version: '13.2.1', @@ -34,7 +36,9 @@ export default function ShareXDialog({ open, onClose, token }) { RequestMethod: 'POST', RequestURL: `${apiUrl}/shorten`, Headers: { - Authorization: token + Authorization: token, + Generator: generator, + ...(usePassword && { Password: '$prompt:Password$' }) }, Body: 'FormURLEncoded', Arguments: { @@ -44,8 +48,8 @@ export default function ShareXDialog({ open, onClose, token }) { ErrorMessage: '$json:error$' }; const a = document.createElement('a'); - a.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(shortener ? shortenerConfig : uploaderConfig, null, '\t'))); - a.setAttribute('download', `${name.replaceAll(' ', '_')}.sxcu`); + a.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(tab === 0 ? uploaderConfig : shortenerConfig, null, '\t'))); + a.setAttribute('download', `${name.replaceAll(' ', '_')}_${tab === 0 ? 'Uploader' : 'Shortener'}.sxcu`); a.click(); }; return ( @@ -55,36 +59,66 @@ export default function ShareXDialog({ open, onClose, token }) { isOpen={open} scrollBehavior='inside' > - + ShareX config generator - + - Config name - setName(n.target.value)} - placeholder='Void' - size='sm' - /> - URL generator - - Preserve file name - setPreserveFileName(p.target.checked)}/> + setTab(index)} size='sm'> + + Uploader + Shortener + + + + Config name + setName(n.target.value)} + placeholder='Void' + size='sm' + /> + URL generator + + Preserve file name + setPreserveFileName(p.target.checked)} /> + + + Config name + setName(n.target.value)} + placeholder='Void' + size='sm' + /> + URL generator + + Use password + setUsePassword(p.target.checked)} /> + + + - - - + + diff --git a/src/components/pages/Upload.tsx b/src/components/pages/Upload.tsx index bf91c13..7e695f7 100644 --- a/src/components/pages/Upload.tsx +++ b/src/components/pages/Upload.tsx @@ -36,13 +36,13 @@ export default function Upload() { body }); const json = await res.json(); - if (res.ok && !json.error) { + if (!json.error) { showToast('success', 'File uploaded', json.url); if (copy(json.url)) 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) { showToast('error', 'Error while uploading the file', error.message); diff --git a/src/components/pages/Urls.tsx b/src/components/pages/Urls.tsx index 4000ef9..8e0d5c4 100644 --- a/src/components/pages/Urls.tsx +++ b/src/components/pages/Urls.tsx @@ -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 { Field, Form, Formik } from 'formik'; import useFetch from 'lib/hooks/useFetch'; @@ -16,6 +16,7 @@ export default function URLs() { const schema = yup.object({ destination: yup.string().matches(/((?:(?:http?|ftp)[s]*:\/\/)?[a-z0-9-%\/\&=?\.]+\.[a-z]{2,4}\/?([^\s<>\#%"\,\{\}\\|\\\^\[\]`]+)?)/i).min(3).required(), vanity: yup.string(), + generator: yup.string(), urlPassword: yup.string() }); const handleDelete = async u => { @@ -45,7 +46,8 @@ export default function URLs() { const data = { destination: schemify(values.destination.trim()), vanity: values.vanity.trim(), - password: values.urlPassword.trim() + password: values.urlPassword.trim(), + generator: values.generator }; setBusy(true); const res = await useFetch('/api/shorten', 'POST', data); @@ -82,7 +84,7 @@ export default function URLs() { - { handleSubmit(values, actions); }}> + { handleSubmit(values, actions); }}> {props => (
@@ -90,7 +92,7 @@ export default function URLs() { {({ field, form }) => ( Destination - + )} @@ -98,7 +100,19 @@ export default function URLs() { {({ field }) => ( Vanity URL - + + + )} + + + {({ field }) => ( + + URL generator + )} @@ -106,7 +120,7 @@ export default function URLs() { {({ field }) => ( Password - + )} diff --git a/src/lib/configReader.ts b/src/lib/configReader.ts index 3d04d9b..e981bc8 100644 --- a/src/lib/configReader.ts +++ b/src/lib/configReader.ts @@ -15,6 +15,7 @@ const envValues = [ e('UPLOADER_RAW_ROUTE', 'string', (c, v) => c.uploader.raw_route = v), e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = 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('BOT_ENABLED', 'boolean', (c, v) => c.bot.enabled = v), diff --git a/src/lib/types.ts b/src/lib/types.ts index a7cc9f3..a919652 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,7 @@ export interface Uploader { raw_route: string; length: number; directory: string; + max_size: number; blacklisted: string[]; } diff --git a/src/lib/validateConfig.ts b/src/lib/validateConfig.ts index c3ec22b..98fe8f8 100644 --- a/src/lib/validateConfig.ts +++ b/src/lib/validateConfig.ts @@ -6,7 +6,7 @@ const validator = yup.object({ secret: yup.string().min(8).required(), host: yup.string().default('0.0.0.0'), port: yup.number().default(3000), - database_url: yup.string().required(), + database_url: yup.string().required() }).required(), bot: yup.object({ enabled: yup.bool().default(false), @@ -26,7 +26,8 @@ const validator = yup.object({ raw_route: yup.string().required(), length: yup.number().default(6), directory: yup.string().required(), - blacklisted: yup.array().default([]), + max_size: yup.number().default(104857600), + blacklisted: yup.array().default([]) }).required(), }); diff --git a/src/pages/[...id].tsx b/src/pages/[...id].tsx index 7cc1c5d..6f54338 100644 --- a/src/pages/[...id].tsx +++ b/src/pages/[...id].tsx @@ -115,6 +115,7 @@ export const getServerSideProps: GetServerSideProps = async context => { const slug = context.params.id[0]; if (slug === config.shortener.route.split('/').pop()) { const short = context.params.id[1]; + if ((short ?? '').trim() === '') return { notFound: true }; const url = await prisma.url.findFirst({ where: { short diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 151d5bf..4969756 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -4,7 +4,7 @@ import { verifyPassword } from 'lib/utils'; import { NextApiReq, NextApiRes, withVoid } from 'middleware/withVoid'; 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 user = await prisma.user.findFirst({ where: { diff --git a/src/pages/api/shorten.ts b/src/pages/api/shorten.ts index 36c7be2..adac87c 100644 --- a/src/pages/api/shorten.ts +++ b/src/pages/api/shorten.ts @@ -1,5 +1,5 @@ -import { default as config } from 'lib/config'; -import generate from 'lib/generators'; +import config from 'lib/config'; +import generate, { emoji, zws } from 'lib/generators'; import { info } from 'lib/logger'; import { NextApiReq, NextApiRes, withVoid } from 'lib/middleware/withVoid'; 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'); } - 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); const url = await prisma.url.create({ data: { - short: req.body.vanity ? req.body.vanity : rand, + short: req.body.vanity || rand, destination: req.body.destination, userId: user.id, password diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index 330751d..5ad7756 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -23,23 +23,10 @@ async function handler(req: NextApiReq, res: NextApiRes) { if (!user) return res.forbid('Unauthorized'); if (!req.file) return res.error('No file specified'); 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); - let slug; - 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 slug = req.headers.generator === 'zws' ? zws(cfg.uploader.length) : req.headers.generator === 'emoji' ? emoji(cfg.uploader.length) : rand; const deletionToken = generate(15); function getMimetype(current, ext) { if (current === 'application/octet-stream') { diff --git a/twilight/commands/shorten.ts b/twilight/commands/shorten.ts index 22c1bf6..0b5fcc8 100644 --- a/twilight/commands/shorten.ts +++ b/twilight/commands/shorten.ts @@ -28,10 +28,9 @@ const shorten = { id: 1 } }); - const rand = generate(config.shortener.length); const url = await prisma.url.create({ data: { - short: vanity ? vanity : rand, + short: vanity ?? generate(config.shortener.length), destination: schemify(dest), userId: config.bot.default_uid, } diff --git a/twilight/commands/upload.ts b/twilight/commands/upload.ts index 0269f64..008e9b7 100644 --- a/twilight/commands/upload.ts +++ b/twilight/commands/upload.ts @@ -29,21 +29,7 @@ const upload = { const fileName = url.parse(args[0]).pathname; const ext = extname(fileName); const rand = generate(config.uploader.length); - let slug; - 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 slug = args[1] === 'zws' ? zws(config.uploader.length) : args[1] === 'emoji' ? emoji(config.uploader.length) : rand; const deletionToken = generate(15); function getMimetype(current, ext) { if (current === 'application/octet-stream') { diff --git a/twilight/twilight.ts b/twilight/twilight.ts index 47f5363..f547422 100644 --- a/twilight/twilight.ts +++ b/twilight/twilight.ts @@ -1,5 +1,5 @@ import Discord, { Message, MessageEmbed } from 'discord.js'; -import { readdir } from 'fs'; +import { readdirSync } from 'fs'; import { error, info } from '../src/lib/logger'; import { Logger } from './utils/logger'; @@ -16,14 +16,13 @@ client.once('ready', () => { avatarUrl = client.user.displayAvatarURL(); global.logger = new Logger(client); global.logger.log('Twilight is ready'); - readdir(`${__dirname}/commands`, (err, files) => { - if(err) error('BOT', err.message); - files.forEach(file => { - if (file.toString().includes('.ts')) { - import(`${__dirname}/commands/${file.toString()}`).then(command => commands.push(command.default)); - info('COMMAND', `Loaded command: ${file.toString().split('.').shift()}`);} + readdirSync(`${__dirname}/commands`) + .map(file => file.toString()) + .filter(file => file.endsWith('.ts')) + .forEach(file => { + import(`${__dirname}/commands/${file.toString()}`).then(command => commands.push(command.default)); + info('COMMAND', `Loaded command: ${file.toString().split('.').shift()}`); }); - }); }); client.on('message', (msg: Message) => {