mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: shorten urls & root path support for everything ever
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
75
src/components/pages/urls/UrlCard.tsx
Normal file
75
src/components/pages/urls/UrlCard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/components/pages/urls/actions.tsx
Normal file
69
src/components/pages/urls/actions.tsx
Normal 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');
|
||||
}
|
||||
170
src/components/pages/urls/index.tsx
Normal file
170
src/components/pages/urls/index.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
src/components/pages/urls/views/UrlGridView.tsx
Normal file
49
src/components/pages/urls/views/UrlGridView.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
src/components/pages/urls/views/UrlTableView.tsx
Normal file
121
src/components/pages/urls/views/UrlTableView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export default function DashboardUsers() {
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='blue'
|
||||
color='gray'
|
||||
radius='sm'
|
||||
leftIcon={<IconUserPlus size='1rem' />}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
3
src/lib/db/models/url.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Url as PrismaUrl } from "@prisma/client"
|
||||
|
||||
export type Url = PrismaUrl;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } } }),
|
||||
|
||||
39
src/pages/api/user/urls/[id].ts
Normal file
39
src/pages/api/user/urls/[id].ts
Normal 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);
|
||||
99
src/pages/api/user/urls/index.ts
Normal file
99
src/pages/api/user/urls/index.ts
Normal 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/pages/dashboard/urls.tsx
Normal file
19
src/pages/dashboard/urls.tsx
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
52
src/server/routes/files.ts
Normal file
52
src/server/routes/files.ts
Normal 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
26
src/server/routes/urls.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user