mirror of https://github.com/AlphaNecron/Void.git
feat(api): password-protected urls
This commit is contained in:
parent
818e35bc9e
commit
9767e405bf
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "void",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "password" TEXT;
|
|
@ -27,6 +27,7 @@ model Url {
|
|||
destination String
|
||||
short String
|
||||
createdAt DateTime @default(now())
|
||||
password String?
|
||||
views Int @default(0)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
|
|
|
@ -19,7 +19,7 @@ const dev = process.env.NODE_ENV === 'development';
|
|||
try {
|
||||
const config = await validateConfig(configReader());
|
||||
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
|
||||
if (data.includes('Following migration have not yet been applied')) {
|
||||
if (data.match(/Following migration[s]? have not yet been applied/)) {
|
||||
info('DB', 'Some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
info('DB', 'Finished applying migrations');
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Center, Checkbox, Heading, HStack, Select, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
|
||||
import { Button, Center, Checkbox, Heading, HStack, Select, Input, Text, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import React, { useState } from 'react';
|
||||
|
@ -51,25 +51,22 @@ export default function Upload() {
|
|||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const fg = useColorModeValue('gray.800', 'white');
|
||||
const bg = useColorModeValue('gray.100', 'gray.700');
|
||||
const shadow = useColorModeValue('outline', 'dark-lg');
|
||||
return (
|
||||
<Center h='92vh'>
|
||||
<VStack
|
||||
px={2}
|
||||
boxShadow='xl'
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
bg={useColorModeValue('gray.100', 'gray.700')}
|
||||
fg={useColorModeValue('gray.800', 'white')}
|
||||
p={2}
|
||||
borderRadius={4}
|
||||
shadow={shadow}>
|
||||
shadow={useColorModeValue('outline', 'dark-lg')}>
|
||||
<Heading fontSize='lg' m={1} align='left'>Upload a file</Heading>
|
||||
<Button m={2} variant='ghost' width='385' height='200'>
|
||||
<Dropzone disabled={busy} onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<VStack {...getRootProps()}>
|
||||
<input {...getInputProps()}/>
|
||||
<Input {...getInputProps()}/>
|
||||
<UploadIcon size={56}/>
|
||||
{isDragActive ? (
|
||||
<Text fontSize='xl'>Drop the file here</Text>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 copy from 'copy-to-clipboard';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ExternalLink, Scissors, Trash2, X } from 'react-feather';
|
||||
|
@ -15,7 +15,8 @@ export default function URLs() {
|
|||
const toast = useToast();
|
||||
const schema = yup.object({
|
||||
destination: yup.string().matches(/((?:(?:http?|ftp)[s]*:\/\/)?[a-z0-9-%\/\&=?\.]+\.[a-z]{2,4}\/?([^\s<>\#%"\,\{\}\\|\\\^\[\]`]+)?)/gi).min(3).required(),
|
||||
vanity: yup.string()
|
||||
vanity: yup.string(),
|
||||
urlPassword: yup.string()
|
||||
});
|
||||
const handleDelete = async u => {
|
||||
const res = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
|
@ -41,9 +42,11 @@ export default function URLs() {
|
|||
setBusy(false);
|
||||
};
|
||||
const handleSubmit = async (values, actions) => {
|
||||
alert(JSON.stringify(values));
|
||||
const data = {
|
||||
destination: schemify(values.destination.trim()),
|
||||
vanity: values.vanity.trim()
|
||||
vanity: values.vanity.trim(),
|
||||
password: values.urlPassword.trim()
|
||||
};
|
||||
setBusy(true);
|
||||
const res = await useFetch('/api/shorten', 'POST', data);
|
||||
|
@ -79,7 +82,7 @@ export default function URLs() {
|
|||
</PopoverHeader>
|
||||
<PopoverArrow/>
|
||||
<PopoverCloseButton/>
|
||||
<Formik validationSchema={schema} initialValues={{ destination: '', vanity: '' }} onSubmit={(values, actions) => { handleSubmit(values, actions); }}>
|
||||
<Formik validationSchema={schema} initialValues={{ destination: '', vanity: '', password: '' }} onSubmit={(values, actions) => { handleSubmit(values, actions); }}>
|
||||
{props => (
|
||||
<Form>
|
||||
<PopoverBody>
|
||||
|
@ -99,6 +102,14 @@ export default function URLs() {
|
|||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name='urlPassword'>
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel htmlFor='urlPassword'>Password</FormLabel>
|
||||
<Input {...field} size='sm' id='urlPassword' mb={4} placeholder='Password'/>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
</PopoverBody>
|
||||
<PopoverFooter
|
||||
border='0'
|
||||
|
|
|
@ -88,7 +88,7 @@ export const withVoid = (handler: (req: NextApiRequest, res: NextApiResponse) =>
|
|||
if (!userId) return null;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
id: +userId
|
||||
},
|
||||
select: {
|
||||
isAdmin: true,
|
||||
|
|
|
@ -8,7 +8,7 @@ export async function hashPassword(s: string): Promise<string> {
|
|||
return await hash(s);
|
||||
}
|
||||
|
||||
export function checkPassword(s: string, hash: string): Promise<boolean> {
|
||||
export function verifyPassword(s: string, hash: string): Promise<boolean> {
|
||||
return verify(hash, s);
|
||||
}
|
||||
|
||||
|
@ -58,4 +58,4 @@ export function bytesToHr(bytes: number) {
|
|||
++num;
|
||||
}
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
}
|
|
@ -1,16 +1,58 @@
|
|||
import { Box, Button, Center, Heading, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Box, Button, Center, Heading, Input, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
|
||||
import FileViewer from 'components/FileViewer';
|
||||
import config from 'lib/config';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import languages from 'lib/languages';
|
||||
import prisma from 'lib/prisma';
|
||||
import { bytesToHr } from 'lib/utils';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import Head from 'next/head';
|
||||
import fetch from 'node-fetch';
|
||||
import React from 'react';
|
||||
import { DownloadCloud } from 'react-feather';
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRightCircle, DownloadCloud } from 'react-feather';
|
||||
|
||||
export default function Embed({ file, embed, username, content = undefined, misc }) {
|
||||
export default function Id({ type, data }) {
|
||||
return type === 'file' ? <Preview {...data}/> : type === 'url' ? <Url {...data}/> : null;
|
||||
}
|
||||
|
||||
function Url({ id }) {
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
const verify = async () => {
|
||||
setBusy(true);
|
||||
const res = await useFetch('/api/validate', 'POST', { id, password: typed });
|
||||
console.log(res);
|
||||
if (res.success) window.location.href = res.destination;
|
||||
else toast({
|
||||
title: 'Wrong password',
|
||||
status: 'error',
|
||||
isClosable: true,
|
||||
duration: 4000
|
||||
});
|
||||
setBusy(false);
|
||||
};
|
||||
return (
|
||||
<Center h='100vh'>
|
||||
<VStack
|
||||
px={4}
|
||||
pt={4}
|
||||
pb={2}
|
||||
boxShadow='xl'
|
||||
bg={useColorModeValue('gray.100', 'gray.700')}
|
||||
fg={useColorModeValue('gray.800', 'white')}
|
||||
borderRadius={5}
|
||||
textAlign='center'
|
||||
shadow={useColorModeValue('outline', 'dark-lg')}>
|
||||
<Heading fontSize='lg'>Please enter the password to continue</Heading>
|
||||
<Input placeholder='Password' value={typed} onChange={p => setTyped(p.target.value)}/>
|
||||
<Button isLoading={busy} colorScheme='purple' rightIcon={<ArrowRightCircle size={24}/>} onClick={() => verify()}>Go</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ file, embed, username, content = undefined, misc }) {
|
||||
const handleDownload = () => {
|
||||
const a = document.createElement('a');
|
||||
a.download = file.origFileName;
|
||||
|
@ -79,6 +121,7 @@ export const getServerSideProps: GetServerSideProps = async context => {
|
|||
},
|
||||
select: {
|
||||
id: true,
|
||||
password: true,
|
||||
destination: true
|
||||
}
|
||||
});
|
||||
|
@ -93,9 +136,20 @@ export const getServerSideProps: GetServerSideProps = async context => {
|
|||
}
|
||||
}
|
||||
});
|
||||
const { destination, password } = url;
|
||||
if (url.password) {
|
||||
return {
|
||||
props: {
|
||||
type: 'url',
|
||||
data: {
|
||||
id: url.id
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
redirect: {
|
||||
destination: url.destination,
|
||||
destination
|
||||
},
|
||||
props: undefined,
|
||||
};
|
||||
|
@ -173,15 +227,18 @@ export const getServerSideProps: GetServerSideProps = async context => {
|
|||
delete file.uploadedAt;
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
embed,
|
||||
username,
|
||||
misc: {
|
||||
ext,
|
||||
type,
|
||||
language: isCode ? ext : 'text'
|
||||
},
|
||||
content
|
||||
type: 'file',
|
||||
data: {
|
||||
file,
|
||||
embed,
|
||||
username,
|
||||
misc: {
|
||||
ext,
|
||||
type,
|
||||
language: isCode ? ext : 'text'
|
||||
},
|
||||
content
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
@ -190,13 +247,16 @@ export const getServerSideProps: GetServerSideProps = async context => {
|
|||
delete file.uploadedAt;
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
embed,
|
||||
username,
|
||||
misc: {
|
||||
ext,
|
||||
type,
|
||||
src
|
||||
type: 'file',
|
||||
data: {
|
||||
file,
|
||||
embed,
|
||||
username,
|
||||
misc: {
|
||||
ext,
|
||||
type,
|
||||
src
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { info } from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { checkPassword, createToken, hashPassword } from 'lib/utils';
|
||||
import { verifyPassword, createToken, hashPassword } from 'lib/utils';
|
||||
import { NextApiReq, NextApiRes, withVoid } from 'middleware/withVoid';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
@ -24,7 +24,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
}
|
||||
});
|
||||
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
||||
const valid = await checkPassword(password, user.password);
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) return res.forbid('Wrong password');
|
||||
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
|
||||
info('AUTH', `User ${user.username} (${user.id}) logged in`);
|
||||
|
|
|
@ -3,6 +3,7 @@ import generate from 'lib/generators';
|
|||
import { info } from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, withVoid } from 'lib/middleware/withVoid';
|
||||
import prisma from 'lib/prisma';
|
||||
import { hashPassword } from 'lib/utils';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('Invalid method');
|
||||
|
@ -25,12 +26,14 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
if (existing) return res.error('Vanity is already taken');
|
||||
}
|
||||
const rand = generate(cfg.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,
|
||||
destination: req.body.destination,
|
||||
userId: user.id,
|
||||
},
|
||||
password
|
||||
}
|
||||
});
|
||||
info('URL', `User ${user.username} (${user.id}) shortened a URL: ${url.destination} (${url.id})`);
|
||||
return res.json({
|
||||
|
|
|
@ -50,7 +50,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
});
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(user.id)
|
||||
id: +user.id
|
||||
},
|
||||
select: {
|
||||
isAdmin: true,
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { default as cfg, default as config } from 'lib/config';
|
||||
import generate from 'lib/generators';
|
||||
import { info } from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, withVoid } from 'lib/middleware/withVoid';
|
||||
import prisma from 'lib/prisma';
|
||||
import { verifyPassword } from 'lib/utils';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('Invalid method');
|
||||
if (!req.body) return res.forbid('No body');
|
||||
if (!(req.body.password || !req.body.id)) return res.forbid('No password or ID');
|
||||
const url = await prisma.url.findFirst({
|
||||
where: {
|
||||
id: +req.body.id
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
destination: true
|
||||
}
|
||||
});
|
||||
const valid = await verifyPassword(req.body.password, url.password);
|
||||
if (!valid) return res.error('Wrong password');
|
||||
return res.json({
|
||||
success: true,
|
||||
destination: url.destination
|
||||
});
|
||||
}
|
||||
|
||||
export default withVoid(handler);
|
Loading…
Reference in New Issue