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'
length = 6
directory = './uploads'
max_size = 104857600
blacklisted = ['exe']

View File

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

View File

@ -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

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 { 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'
>
<ModalOverlay/>
<ModalOverlay />
<ModalContent>
<ModalHeader>ShareX config generator</ModalHeader>
<ModalCloseButton/>
<ModalCloseButton />
<ModalBody>
<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'>Preserve file name</Heading>
<Switch isChecked={preserveFileName} onChange={p => setPreserveFileName(p.target.checked)}/>
<Tabs index={tab} onChange={index => setTab(index)} size='sm'>
<TabList>
<Tab>Uploader</Tab>
<Tab>Shortener</Tab>
</TabList>
<TabPanels>
<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'>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>
<ModalFooter>
<ButtonGroup size='sm'>
<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={() => generateConfig(false)} ref={ref}>Uploader</Button>
<Button onClick={onClose} leftIcon={<X size={16} />}>Cancel</Button>
<Button colorScheme='purple' leftIcon={<Download size={16} />} onClick={() => downloadConfig()} ref={ref}>Download</Button>
</ButtonGroup>
</ModalFooter>
</ModalContent>

View File

@ -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);

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 { 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() {
</PopoverHeader>
<PopoverArrow/>
<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 => (
<Form>
<PopoverBody>
@ -90,7 +92,7 @@ export default function URLs() {
{({ field, form }) => (
<FormControl isInvalid={form.errors.destination && form.touched.destination} isRequired>
<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>
)}
</Field>
@ -98,7 +100,19 @@ export default function URLs() {
{({ field }) => (
<FormControl>
<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>
)}
</Field>
@ -106,7 +120,7 @@ export default function URLs() {
{({ field }) => (
<FormControl>
<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>
)}
</Field>

View File

@ -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),

View File

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

View File

@ -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(),
});

View File

@ -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

View File

@ -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: {

View File

@ -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

View File

@ -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') {

View File

@ -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,
}

View File

@ -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') {

View File

@ -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) => {