feat: shorten urls & root path support for everything ever

This commit is contained in:
diced
2023-07-20 17:42:30 -07:00
parent e0bfb0ddeb
commit fa30d082fd
23 changed files with 788 additions and 52 deletions

View File

@@ -14,7 +14,8 @@
"start": "node ./build/server.mjs",
"lint": "eslint --cache --ignore-path .gitignore --fix .",
"format": "prettier --write --ignore-path .gitignore .",
"validate": "run-p lint format"
"validate": "run-p lint format",
"db:prototype": "prisma db push && prisma generate"
},
"dependencies": {
"@emotion/react": "^11.11.1",

View File

@@ -108,11 +108,10 @@ model File {
size Int
type String
views Int @default(0)
maxViews Int?
favorite Boolean @default(false)
password String?
zeroWidthSpace String?
tags Tag[]
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@ -173,15 +172,16 @@ model Url {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
code String
vanity String?
destination String
name String @unique
views Int @default(0)
zeroWidthSpace String?
maxViews Int?
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String?
@@unique([code, vanity])
}
model Metric {

View File

@@ -23,13 +23,15 @@ export function copyFile(
) {
const domain = `${window.location.protocol}//${window.location.host}`;
clipboard.copy(`${domain}/view/${file.name}`);
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
clipboard.copy(url);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/view/${file.name}`}>
{`${domain}/view/${file.name}`}
<Anchor component={Link} href={url}>
{url}
</Anchor>
),
color: 'green',

View File

@@ -0,0 +1,75 @@
import { useConfig } from '@/components/ConfigProvider';
import RelativeDate from '@/components/RelativeDate';
import { Url } from '@/lib/db/models/url';
import { formatRootUrl } from '@/lib/url';
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import { copyUrl, deleteUrl } from './actions';
import { useClipboard } from '@mantine/hooks';
export default function UserCard({ url }: { url: Url }) {
const config = useConfig();
const clipboard = useClipboard();
return (
<>
<Card withBorder shadow='sm' radius='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group position='apart'>
<Text weight={400}>
<Anchor
href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}
target='_blank'
rel='noopener noreferrer'
>
{url.vanity ?? url.code}
</Anchor>
</Text>
<Menu withinPortal position='bottom-end' shadow='sm'>
<Group spacing={2}>
<Menu.Target>
<ActionIcon>
<IconDots size='1rem' />
</ActionIcon>
</Menu.Target>
</Group>
<Menu.Dropdown>
<Menu.Item icon={<IconCopy size='1rem' />} onClick={() => copyUrl(url, config, clipboard)}>
Copy
</Menu.Item>
<Menu.Item icon={<IconTrashFilled size='1rem' />} color='red' onClick={() => deleteUrl(url)}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs'>
<Stack spacing={1}>
<Text size='xs' color='dimmed'>
<b>Created:</b> <RelativeDate date={url.createdAt} />
</Text>
<Text size='xs' color='dimmed'>
<b>Updated:</b> <RelativeDate date={url.updatedAt} />
</Text>
<Text size='xs' color='dimmed'>
<b>Destination:</b>{' '}
<Anchor href={url.destination} target='_blank' rel='noopener noreferrer'>
{url.destination}
</Anchor>
</Text>
{url.vanity && (
<Text size='xs' color='dimmed'>
<b>Code:</b> {url.code}
</Text>
)}
</Stack>
</Card.Section>
</Card>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { Response } from '@/lib/api/response';
import type { SafeConfig } from '@/lib/config/safe';
import { Url } from '@/lib/db/models/url';
import { fetchApi } from '@/lib/fetchApi';
import { formatRootUrl } from '@/lib/url';
import { Anchor, Title } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconLinkOff } from '@tabler/icons-react';
import Link from 'next/link';
import { mutate } from 'swr';
export async function deleteUrl(url: Url) {
modals.openConfirmModal({
centered: true,
title: <Title>Delete {url.code ?? url.vanity}?</Title>,
children: `Are you sure you want to delete ${url.code ?? url.vanity}? This action cannot be undone.`,
labels: {
cancel: 'Cancel',
confirm: 'Delete',
},
confirmProps: { color: 'red' },
onConfirm: () => handleDeleteUrl(url),
onCancel: modals.closeAll,
});
}
export function copyUrl(url: Url, config: SafeConfig, clipboard: ReturnType<typeof useClipboard>) {
const domain = `${window.location.protocol}//${window.location.host}`;
clipboard.copy(`${domain}${formatRootUrl(config.urls.route, url.vanity ?? url.code)}`);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}>
{`${domain}${formatRootUrl(config.urls.route, url.vanity ?? url.code)}`}
</Anchor>
),
color: 'green',
icon: <IconCopy size='1rem' />,
});
}
async function handleDeleteUrl(url: Url) {
const { data, error } = await fetchApi<Response['/api/user/urls/[id]']>(
`/api/user/urls/${url.id}`,
'DELETE'
);
if (error) {
notifications.show({
title: 'Failed to delete url',
message: error.message,
color: 'red',
icon: <IconLinkOff size='1rem' />,
});
} else {
notifications.show({
title: 'Url deleted',
message: `Url ${data?.code ?? data?.vanity} has been deleted`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
}
mutate('/api/user/urls');
}

View File

@@ -0,0 +1,170 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import {
ActionIcon,
Anchor,
Button,
Group,
Modal,
NumberInput,
Stack,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { hasLength, useForm } from '@mantine/form';
import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import { mutate } from 'swr';
import UrlGridView from './views/UrlGridView';
import UrlTableView from './views/UrlTableView';
export default function DashboardURLs() {
const clipboard = useClipboard();
const view = useViewStore((state) => state.urls);
const [open, setOpen] = useState(false);
const form = useForm<{
url: string;
vanity: string;
maxViews: '' | number;
}>({
initialValues: {
url: '',
vanity: '',
maxViews: '',
},
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');
}
const { data, error } = await fetchApi<Extract<Response['/api/user/urls'], { url: string }>>(
`/api/user/urls`,
'POST',
{
destination: values.url,
vanity: values.vanity.trim() || null,
},
values.maxViews !== '' ? { 'x-zipline-max-views': String(values.maxViews) } : {}
);
if (error) {
notifications.show({
title: 'Failed to shorten URL',
message: error.message,
color: 'red',
icon: <IconLinkOff size='1rem' />,
});
} else {
setOpen(false);
const open = () => window.open(data?.url, '_blank');
const copy = () => {
clipboard.copy(data?.url);
notifications.show({
title: 'Copied URL to clipboard',
message: (
<Anchor component={Link} href={data?.url ?? ''} target='_blank'>
{data?.url}
</Anchor>
),
color: 'blue',
icon: <IconClipboardCopy size='1rem' />,
});
};
modals.open({
title: <Title>Shortened URL</Title>,
size: 'auto',
children: (
<Group position='apart'>
<Group position='left'>
<Anchor component={Link} href={data?.url ?? ''}>
{data?.url}
</Anchor>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open()} variant='filled' color='primary'>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy()} variant='filled' color='primary'>
<IconClipboardCopy size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
</Group>
),
});
mutate('/api/user/urls');
form.reset();
}
};
return (
<>
<Modal centered opened={open} onClose={() => setOpen(false)} title={<Title>Shorten a URL</Title>}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack spacing='sm'>
<TextInput label='URL' placeholder='https://example.com' {...form.getInputProps('url')} />
<TextInput
label='Vanity'
description='Optional field, leave blank to generate a random code'
placeholder='example'
{...form.getInputProps('vanity')}
/>
<NumberInput
label='Max views'
description='Optional field, leave blank to disable a view limit.'
min={0}
{...form.getInputProps('maxViews')}
/>
<Button
type='submit'
variant='outline'
color='gray'
radius='sm'
leftIcon={<IconLink size='1rem' />}
>
Create
</Button>
</Stack>
</form>
</Modal>
<Group>
<Title>URLs</Title>
<Tooltip label='Shorten a URL'>
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
<IconLink size='1rem' />
</ActionIcon>
</Tooltip>
<GridTableSwitcher type='urls' />
</Group>
{view === 'grid' ? <UrlGridView /> : <UrlTableView />}
</>
);
}

View File

@@ -0,0 +1,49 @@
import { Response } from '@/lib/api/response';
import type { Url } from '@/lib/db/models/url';
import { Center, Group, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { IconLink } from '@tabler/icons-react';
import useSWR from 'swr';
import UrlCard from '../UrlCard';
export default function UrlGridView() {
const { data: urls, isLoading } = useSWR<Extract<Response['/api/user/urls'], Url[]>>('/api/user/urls');
return (
<>
{isLoading ? (
<Paper withBorder h={200}>
<LoadingOverlay visible />
</Paper>
) : urls?.length ?? 0 !== 0 ? (
<SimpleGrid
my='sm'
spacing='md'
cols={4}
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
{ maxWidth: 'md', cols: 2 },
]}
pos='relative'
>
{urls?.map((url) => (
<UrlCard key={url.id} url={url} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>
<Center>
<Stack>
<Group>
<IconLink size='2rem' />
<Title order={2}>No URLs found</Title>
</Group>
<Text size='sm' color='dimmed'>
Shorten a URL to see them here
</Text>
</Stack>
</Center>
</Paper>
)}
</>
);
}

View File

@@ -0,0 +1,121 @@
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Url } from '@/lib/db/models/url';
import { ActionIcon, Anchor, Box, Group, Tooltip } from '@mantine/core';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { copyUrl, deleteUrl } from '../actions';
import { IconCopy, IconTrashFilled } from '@tabler/icons-react';
import { useConfig } from '@/components/ConfigProvider';
import { useClipboard } from '@mantine/hooks';
export default function UrlTableView() {
const config = useConfig();
const clipboard = useClipboard();
const { data, isLoading } = useSWR<Extract<Response['/api/user/urls'], Url[]>>('/api/user/urls');
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Url[]>(data ?? []);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Url;
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
setSorted(sorted);
}
}, [sortStatus]);
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
return (
<>
<Box my='sm'>
<DataTable
borderRadius='sm'
withBorder
minHeight={200}
records={sorted ?? []}
columns={[
{
accessor: 'code',
sortable: true,
},
{
accessor: 'vanity',
sortable: true,
render: (url) => url.vanity ?? <b>None</b>,
},
{
accessor: 'destination',
sortable: true,
render: (url) => (
<Anchor href={url.destination} target='_blank' rel='noreferrer'>
{url.destination}
</Anchor>
),
},
{
accessor: 'maxViews',
sortable: true,
render: (url) => (url.maxViews ? url.maxViews : <b>None</b>),
},
{
accessor: 'createdAt',
title: 'Created',
sortable: true,
render: (url) => <RelativeDate date={url.createdAt} />,
},
{
accessor: 'actions',
width: 150,
render: (url) => (
<Group spacing='sm'>
<Tooltip label='Copy URL'>
<ActionIcon
variant='outline'
color='gray'
onClick={(e) => {
e.stopPropagation();
copyUrl(url, config, clipboard);
}}
>
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete URL'>
<ActionIcon
variant='outline'
color='red'
onClick={(e) => {
e.stopPropagation();
deleteUrl(url);
}}
>
<IconTrashFilled size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
),
},
]}
fetching={isLoading}
sortStatus={sortStatus}
onSortStatusChange={(s) => setSortStatus(s)}
/>
</Box>
</>
);
}

View File

@@ -144,7 +144,7 @@ export default function DashboardUsers() {
<Button
type='submit'
variant='outline'
color='blue'
color='gray'
radius='sm'
leftIcon={<IconUserPlus size='1rem' />}
>

View File

@@ -11,6 +11,8 @@ import { ApiUserFilesTransactionResponse } from '@/pages/api/user/files/transact
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
import { ApiUserTokenResponse } from '@/pages/api/user/token';
import { ApiUserUrlsResponse } from '@/pages/api/user/urls';
import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]';
import { ApiUsersResponse } from '@/pages/api/users';
import { ApiUsersIdResponse } from '@/pages/api/users/[id]';
@@ -21,6 +23,8 @@ export type Response = {
'/api/user/files/[id]': ApiUserFilesIdResponse;
'/api/user/files/transaction': ApiUserFilesTransactionResponse;
'/api/user/files': ApiUserFilesResponse;
'/api/user/urls/[id]': ApiUserUrlsIdResponse;
'/api/user/urls': ApiUserUrlsResponse;
'/api/user': ApiUserResponse;
'/api/user/stats': ApiUserStatsResponse;
'/api/user/recent': ApiUserRecentResponse;

View File

@@ -22,6 +22,9 @@ export const PROP_TO_ENV: Record<string, string> = {
'files.assumeMimetypes': 'FILES_ASSUME_MIMETYPES',
'files.defaultDateFormat': 'FILES_DEFAULT_DATE_FORMAT',
'urls.route': 'URLS_ROUTE',
'urls.length': 'URLS_LENGTH',
'datasource.type': 'DATASOURCE_TYPE',
// only for errors, not used in readenv
@@ -77,6 +80,9 @@ export function readEnv() {
env(PROP_TO_ENV['files.maxFileSize'], 'files.maxFileSize', 'byte'),
env(PROP_TO_ENV['files.defaultExpiration'], 'files.defaultExpiration', 'ms'),
env(PROP_TO_ENV['urls.route'], 'urls.route', 'string'),
env(PROP_TO_ENV['urls.length'], 'urls.length', 'number'),
env(PROP_TO_ENV['datasource.type'], 'datasource.type', 'string'),
env(PROP_TO_ENV['datasource.s3.accessKeyId'], 'datasource.s3.accessKeyId', 'string'),
@@ -130,6 +136,10 @@ export function readEnv() {
assumeMimetypes: undefined,
defaultDateFormat: undefined,
},
urls: {
route: undefined,
length: undefined,
},
datasource: {
type: undefined,
},

View File

@@ -39,6 +39,10 @@ export const schema = z.object({
assumeMimetypes: z.boolean().default(false),
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'),
}),
urls: z.object({
route: z.string().startsWith('/').nonempty().trim().toLowerCase().default('/go'),
length: z.number().default(6),
}),
datasource: z
.object({
type: z.enum(['local', 's3']).default('local'),

View File

@@ -12,7 +12,6 @@ export type File = {
size: number;
type: string;
views: number;
zeroWidthSpace: string | null;
password?: string | boolean | null;
url?: string;
@@ -29,7 +28,6 @@ export const fileSelect = {
size: true,
type: true,
views: true,
zeroWidthSpace: true,
};
export function cleanFile(file: File) {

3
src/lib/db/models/url.ts Normal file
View File

@@ -0,0 +1,3 @@
import type { Url as PrismaUrl } from "@prisma/client"
export type Url = PrismaUrl;

View File

@@ -4,7 +4,8 @@ import { ErrorBody } from './response';
export async function fetchApi<Response = any, Error = string>(
route: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
body: any = null
body: any = null,
headers: Record<string, string> = {}
): Promise<{
data: Response | null;
error: ErrorBody | null;
@@ -16,6 +17,7 @@ export async function fetchApi<Response = any, Error = string>(
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : null,
});

View File

@@ -95,7 +95,7 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
id: req.user.id,
},
},
// ...(options.maxViews && { maxViews: options.maxViews }),
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),

View File

@@ -0,0 +1,39 @@
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserUrlsIdResponse = Url;
type Query = {
id: string;
};
export async function handler(req: NextApiReq<unknown, Query>, res: NextApiRes<ApiUserUrlsIdResponse>) {
const { id } = req.query;
const url = await prisma.url.findFirst({
where: {
id: id,
},
});
if (!url) return res.notFound();
if (url.userId !== req.user.id) return res.forbidden('You do not own this URL');
if (req.method === 'DELETE') {
const url = await prisma.url.delete({
where: {
id: id,
},
});
return res.ok(url);
}
return res.ok(url);
}
export default combine([method(['GET', 'DELETE']), ziplineAuth()], handler);

View File

@@ -0,0 +1,99 @@
import { config } from '@/lib/config';
import { randomCharacters } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Url } from '@/lib/db/models/url';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserUrlsResponse =
| Url[]
| {
url: string;
};
type Body = {
vanity?: string;
destination: string;
};
type Query = {
'x-zipline-max-views': string;
'x-zipline-no-json': string;
'x-zipline-domain': string;
};
const logger = log('api').c('user').c('urls');
export async function handler(req: NextApiReq<Body, unknown, Query>, res: NextApiRes<ApiUserUrlsResponse>) {
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'];
const maxViewsHeader = req.headers['x-zipline-max-views'];
if (maxViewsHeader) {
maxViews = Number(maxViewsHeader);
if (isNaN(maxViews)) return res.badRequest('Max views must be a number');
if (maxViews < 0) return res.badRequest('Max views must be greater than 0');
}
if (!destination) return res.badRequest('Destination is required');
if (vanity) {
const existingVanity = await prisma.url.findFirst({
where: {
vanity: vanity,
},
});
if (existingVanity) return res.badRequest('Vanity already taken');
}
const url = await prisma.url.create({
data: {
userId: req.user.id,
destination: destination,
code: randomCharacters(config.urls.length),
...(vanity && { vanity: vanity }),
...(maxViews && { maxViews: maxViews }),
},
});
let domain;
if (returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${returnDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
const responseUrl = `${domain}${
config.urls.route === '/' || config.urls.route === '' ? '' : `${config.urls.route}`
}/${url.vanity ?? url.code}`;
logger.info(`${req.user.username} shortened a URL`, {
from: destination,
to: responseUrl,
user: req.user.id,
});
if (noJson) return res.status(200).end(responseUrl);
return res.ok({
url: responseUrl,
});
}
const urls = await prisma.url.findMany({
where: {
userId: req.user.id,
},
});
return res.ok(urls);
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -1,5 +1,5 @@
import Layout from '@/components/Layout';
import DashbaordFiles from '@/components/pages/files';
import DashboardFiles from '@/components/pages/files';
import useLogin from '@/lib/hooks/useLogin';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { LoadingOverlay } from '@mantine/core';
@@ -11,7 +11,7 @@ export default function DashboardIndex({ config }: InferGetServerSidePropsType<t
return (
<Layout config={config}>
<DashbaordFiles />
<DashboardFiles />
</Layout>
);
}

View File

@@ -0,0 +1,19 @@
import Layout from '@/components/Layout';
import DashboardURLs from '@/components/pages/urls';
import useLogin from '@/lib/hooks/useLogin';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { LoadingOverlay } from '@mantine/core';
import { InferGetServerSidePropsType } from 'next';
export default function DashboardIndex({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible />;
return (
<Layout config={config}>
<DashboardURLs />
</Layout>
);
}
export const getServerSideProps = withSafeConfig();

View File

@@ -1,18 +1,18 @@
import { validateEnv } from '@/lib/config/validate';
import { readEnv } from '@/lib/config/read';
import { validateEnv } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { runMigrations } from '@/lib/db/migration';
import { log } from '@/lib/logger';
import express from 'express';
import { mkdir } from 'fs/promises';
import next from 'next';
import { parse } from 'url';
import { mkdir } from 'fs/promises';
import { prisma } from '@/lib/db';
import { datasource } from '@/lib/datasource';
import { guess } from '@/lib/mimes';
import { extname } from 'path';
import { verifyPassword } from '@/lib/crypto';
import { version } from '../../package.json';
import { filesRoute } from './routes/files';
import { urlsRoute } from './routes/urls';
const MODE = process.env.NODE_ENV || 'production';
@@ -48,39 +48,32 @@ async function main() {
await app.prepare();
server.get(config.files.route === '/' ? `/:id` : `${config.files.route}/:id`, async (req, res) => {
const { id } = req.params;
const parsedUrl = parse(req.url!, true);
if (config.files.route === '/' && config.urls.route === '/') {
server.get('/:id', async (req, res) => {
const { id } = req.params;
const parsedUrl = parse(req.url!, true);
if (!id) return app.render404(req, res, parsedUrl);
if (id === '') return app.render404(req, res, parsedUrl);
if (id === 'dashboard') return app.render(req, res, '/dashboard');
if (id === '') return app.render404(req, res, parsedUrl);
else if (id === 'dashboard') return app.render(req, res, '/dashboard');
const file = await prisma.file.findFirst({
where: {
name: id,
},
const url = await prisma.url.findFirst({
where: {
OR: [{ code: id }, { vanity: id }],
},
});
if (url) return urlsRoute.bind(server)(app, req, res);
else return filesRoute.bind(server)(app, req, res);
});
} else {
server.get(config.files.route === '/' ? `/:id` : `${config.files.route}/:id`, async (req, res) => {
filesRoute.bind(server)(app, req, res);
});
if (!file) return app.render404(req, res, parsedUrl);
const stream = await datasource.get(file.name);
if (!stream) return app.render404(req, res, parsedUrl);
if (!file.type && config.files.assumeMimetypes) {
const ext = extname(file.name);
const mime = await guess(ext);
res.setHeader('Content-Type', mime);
} else {
res.setHeader('Content-Type', file.type);
}
res.setHeader('Content-Length', file.size);
file.originalName && res.setHeader('Content-Disposition', `filename="${file.originalName}"`);
stream.pipe(res);
});
server.get(config.urls.route === '/' ? `/:id` : `${config.urls.route}/:id`, async (req, res) => {
urlsRoute.bind(server)(app, req, res);
});
}
server.get('/raw/:id', async (req, res) => {
const { id } = req.params;

View File

@@ -0,0 +1,52 @@
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import express, { Request, Response } from 'express';
import { NextServer } from 'next/dist/server/next';
import { parse } from 'url';
export async function filesRoute(
this: ReturnType<typeof express>,
app: NextServer,
req: Request,
res: Response
) {
const { id } = req.params;
const { pw } = req.query;
const parsedUrl = parse(req.url!, true);
const file = await prisma.file.findFirst({
where: {
name: id,
},
include: {
User: true,
},
});
if (!file) return app.render404(req, res, parsedUrl);
if (file.User?.view.enabled) return res.redirect(`/view/${file.name}`);
const stream = await datasource.get(file.name);
if (!stream) return app.render404(req, res, parsedUrl);
if (file.password) {
if (!pw) return res.status(403).json({ code: 403, message: 'Password protected.' });
const verified = await verifyPassword(pw as string, file.password!);
if (!verified) return res.status(403).json({ code: 403, message: 'Incorrect password.' });
}
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Length', file.size);
file.originalName &&
res.setHeader(
'Content-Disposition',
`${req.query.download ? 'attachment; ' : ''}filename="${file.originalName}"`
);
// todo: add view
stream.pipe(res);
}

26
src/server/routes/urls.ts Normal file
View File

@@ -0,0 +1,26 @@
import { NextServer } from 'next/dist/server/next';
import express, { Request, Response } from 'express';
import { parse } from 'url';
import { prisma } from '@/lib/db';
export async function urlsRoute(
this: ReturnType<typeof express>,
app: NextServer,
req: Request,
res: Response
) {
const { id } = req.params;
const parsedUrl = parse(req.url!, true);
const url = await prisma.url.findFirst({
where: {
OR: [{ code: id }, { vanity: id }],
},
});
if (!url) return app.render404(req, res, parsedUrl);
// todo: add view
return res.redirect(url.destination);
}