Skip to content

Commit

Permalink
feat: url passwords
Browse files Browse the repository at this point in the history
  • Loading branch information
diced committed Mar 7, 2024
1 parent 076a04b commit b02bcfc
Show file tree
Hide file tree
Showing 9 changed files with 191 additions and 9 deletions.
5 changes: 5 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const nextConfig = {
destination: '/auth/register?code=:code',
},
],
webpack: (config) => {
config.resolve.fallback = { worker_threads: false };

return config;
},
};

module.exports = nextConfig;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
20 changes: 14 additions & 6 deletions src/components/pages/urls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Group,
Modal,
NumberInput,
PasswordInput,
Stack,
TextInput,
Title,
Expand All @@ -35,23 +36,21 @@ 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'),
},
});

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<Extract<Response['/api/user/urls'], { url: string }>>(
'/api/user/urls',
Expand All @@ -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) {
Expand Down Expand Up @@ -139,6 +141,12 @@ export default function DashboardURLs() {
{...form.getInputProps('maxViews')}
/>

<PasswordInput
label='Password'
description='Protect your link with a password'
{...form.getInputProps('password')}
/>

<Button type='submit' variant='outline' radius='sm' leftSection={<IconLink size='1rem' />}>
Create
</Button>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/api/user/files/[id]/password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFile
},
});
if (!file) return res.notFound();
if (!file.password) return res.forbidden("This file doesn't have a password");
if (!file.password) return res.notFound();

const verified = await verifyPassword(req.body.password, file.password);
if (!verified) return res.forbidden('Incorrect password');
Expand Down
File renamed without changes.
39 changes: 39 additions & 0 deletions src/pages/api/user/urls/[id]/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';

export type ApiUserUrlsIdPasswordResponse = {
success: boolean;
};

type Body = {
password: string;
};

const logger = log('api').c('user').c('urls').c('$id').c('password');

export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserUrlsIdPasswordResponse>) {
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);
8 changes: 7 additions & 1 deletion src/pages/api/user/urls/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,6 +43,7 @@ export async function handler(req: NextApiReq<Body, Query, Headers>, 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'];

Expand All @@ -53,6 +54,10 @@ export async function handler(req: NextApiReq<Body, Query, Headers>, 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) {
Expand All @@ -72,6 +77,7 @@ export async function handler(req: NextApiReq<Body, Query, Headers>, res: NextAp
code: randomCharacters(config.urls.length),
...(vanity && { vanity: vanity }),
...(maxViews && { maxViews: maxViews }),
...(password && { password: password }),
},
});

Expand Down
114 changes: 114 additions & 0 deletions src/pages/view/url/[id].tsx
Original file line number Diff line number Diff line change
@@ -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<typeof getServerSideProps>) {
const router = useRouter();

const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');

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 ? (
<Modal
onClose={() => {}}
opened={true}
withCloseButton={false}
centered
title={<Title>Password required</Title>}
>
<PasswordInput
description='This link is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>

<Button
fullWidth
variant='outline'
my='sm'
onClick={() => verifyPassword()}
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</Modal>
) : 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,
},
};
};
11 changes: 10 additions & 1 deletion src/server/routes/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand All @@ -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,
Expand Down

0 comments on commit b02bcfc

Please sign in to comment.