diff --git a/next.config.js b/next.config.js index 1a65499c..a64074fe 100644 --- a/next.config.js +++ b/next.config.js @@ -7,6 +7,11 @@ const nextConfig = { destination: '/auth/register?code=:code', }, ], + webpack: (config) => { + config.resolve.fallback = { worker_threads: false }; + + return config; + }, }; module.exports = nextConfig; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 680be2fe..499eb437 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -212,6 +212,7 @@ model Url { destination String views Int @default(0) maxViews Int? + password String? User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) userId String? diff --git a/src/components/pages/urls/index.tsx b/src/components/pages/urls/index.tsx index ea7e57ad..9fdc9a31 100644 --- a/src/components/pages/urls/index.tsx +++ b/src/components/pages/urls/index.tsx @@ -9,6 +9,7 @@ import { Group, Modal, NumberInput, + PasswordInput, Stack, TextInput, Title, @@ -35,11 +36,13 @@ export default function DashboardURLs() { url: string; vanity: string; maxViews: '' | number; + password: string; }>({ initialValues: { url: '', vanity: '', maxViews: '', + password: '', }, validate: { url: hasLength({ min: 1 }, 'URL is required'), @@ -47,11 +50,7 @@ export default function DashboardURLs() { }); const onSubmit = async (values: typeof form.values) => { - try { - new URL(values.url); - } catch { - return form.setFieldError('url', 'Invalid URL'); - } + if (URL.canParse(values.url) === false) return form.setFieldError('url', 'Invalid URL'); const { data, error } = await fetchApi>( '/api/user/urls', @@ -60,7 +59,10 @@ export default function DashboardURLs() { destination: values.url, vanity: values.vanity.trim() || null, }, - values.maxViews !== '' ? { 'x-zipline-max-views': String(values.maxViews) } : {}, + { + ...(values.maxViews !== '' && { 'x-zipline-max-views': String(values.maxViews) }), + ...(values.password !== '' && { 'x-zipline-password': values.password }), + }, ); if (error) { @@ -139,6 +141,12 @@ export default function DashboardURLs() { {...form.getInputProps('maxViews')} /> + + diff --git a/src/pages/api/user/files/[id]/password.ts b/src/pages/api/user/files/[id]/password.ts index 98b3d620..decf77d4 100644 --- a/src/pages/api/user/files/[id]/password.ts +++ b/src/pages/api/user/files/[id]/password.ts @@ -26,7 +26,7 @@ export async function handler(req: NextApiReq, res: NextApiRes, res: NextApiRes) { + const url = await prisma.url.findFirst({ + where: { + OR: [{ id: req.query.id }, { code: req.query.id }, { vanity: req.query.id }], + }, + select: { + password: true, + id: true, + }, + }); + if (!url) return res.notFound(); + if (!url.password) return res.notFound(); + + const verified = await verifyPassword(req.body.password, url.password); + if (!verified) return res.forbidden('Incorrect password'); + + logger.info(`url ${url.id} was accessed with the correct password`, { ua: req.headers['user-agent'] }); + + return res.ok({ success: true }); +} + +export default combine([method(['POST'])], handler); diff --git a/src/pages/api/user/urls/index.ts b/src/pages/api/user/urls/index.ts index 1f15ddd2..8908ae1b 100644 --- a/src/pages/api/user/urls/index.ts +++ b/src/pages/api/user/urls/index.ts @@ -1,5 +1,5 @@ import { config } from '@/lib/config'; -import { randomCharacters } from '@/lib/crypto'; +import { hashPassword, randomCharacters } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { Url } from '@/lib/db/models/url'; import { log } from '@/lib/logger'; @@ -43,6 +43,7 @@ export async function handler(req: NextApiReq, res: NextAp if (req.method === 'POST') { const { vanity, destination } = req.body; const noJson = !!req.headers['x-zipline-no-json']; + let maxViews: number | undefined; const returnDomain = req.headers['x-zipline-domain']; @@ -53,6 +54,10 @@ export async function handler(req: NextApiReq, res: NextAp if (maxViews < 0) return res.badRequest('Max views must be greater than 0'); } + const password = req.headers['x-zipline-password'] + ? await hashPassword(req.headers['x-zipline-password']) + : undefined; + if (!destination) return res.badRequest('Destination is required'); if (vanity) { @@ -72,6 +77,7 @@ export async function handler(req: NextApiReq, res: NextAp code: randomCharacters(config.urls.length), ...(vanity && { vanity: vanity }), ...(maxViews && { maxViews: maxViews }), + ...(password && { password: password }), }, }); diff --git a/src/pages/view/url/[id].tsx b/src/pages/view/url/[id].tsx new file mode 100644 index 00000000..0ef18dd9 --- /dev/null +++ b/src/pages/view/url/[id].tsx @@ -0,0 +1,114 @@ +import { verifyPassword } from '@/lib/crypto'; +import { prisma } from '@/lib/db'; +import { fetchApi } from '@/lib/fetchApi'; +import { Button, Modal, PasswordInput, Title } from '@mantine/core'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; + +export default function ViewUrl({ url, password }: InferGetServerSidePropsType) { + const router = useRouter(); + + const [passwordValue, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + const verifyPassword = async () => { + const { error } = await fetchApi(`/api/user/urls/${url.id}/password`, 'POST', { + password: passwordValue.trim(), + }); + + if (error) { + setPasswordError('Invalid password'); + } else { + setPasswordError(''); + router.replace(`/view/url/${url.id}?pw=${encodeURI(passwordValue.trim())}`); + } + }; + + return password ? ( + {}} + opened={true} + withCloseButton={false} + centered + title={Password required} + > + setPassword(event.currentTarget.value)} + error={passwordError} + /> + + + + ) : null; +} + +export const getServerSideProps: GetServerSideProps<{ + url: { id: string }; + password?: boolean; +}> = async (context) => { + const { id, pw } = context.query as { id: string; pw: string }; + if (!id) return { notFound: true }; + + const url = await prisma.url.findFirst({ + where: { + OR: [{ vanity: id }, { code: id }, { id }], + }, + select: { + id: true, + password: true, + destination: true, + }, + }); + if (!url) return { notFound: true }; + + if (pw) { + const verified = await verifyPassword(pw, url.password!); + // @ts-ignore + delete url.password; + + if (!verified) { + // @ts-ignore + delete url.destination; + + return { + props: { + url, + password: true, + }, + }; + } + + return { + redirect: { + destination: url.destination, + permanent: true, + }, + }; + } + + const password = url.password ? true : false; + // @ts-ignore + delete url.password; + // @ts-ignore + delete url.destination; + + return { + props: { + url, + password, + }, + }; +}; diff --git a/src/server/routes/urls.ts b/src/server/routes/urls.ts index ad7071a0..1c4f68b4 100644 --- a/src/server/routes/urls.ts +++ b/src/server/routes/urls.ts @@ -4,6 +4,7 @@ import { parse } from 'url'; import { prisma } from '@/lib/db'; import { config } from '@/lib/config'; import { log } from '@/lib/logger'; +import { verifyPassword } from '@/lib/crypto'; const logger = log('server').c('urls'); @@ -14,12 +15,13 @@ export async function urlsRoute( res: Response, ) { const { id } = req.params; + const { pw } = req.query; const parsedUrl = parse(req.url!, true); const url = await prisma.url.findFirst({ where: { - OR: [{ code: id }, { vanity: id }], + OR: [{ code: id }, { vanity: id }, { id }], }, }); if (!url) return app.render404(req, res, parsedUrl); @@ -42,6 +44,13 @@ export async function urlsRoute( return app.render404(req, res, parsedUrl); } + if (url.password) { + if (!pw) return res.redirect(`/view/url/${url.id}`); + const verified = await verifyPassword(pw as string, url.password); + + if (!verified) return res.redirect(`/view/url/${url.id}`); + } + await prisma.url.update({ where: { id: url.id,