mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: enable/disable urls on the fly
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -336,6 +336,7 @@ model Url {
|
||||
views Int @default(0)
|
||||
maxViews Int?
|
||||
password String?
|
||||
enabled Boolean @default(true)
|
||||
|
||||
User User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||
userId String?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
@@ -21,6 +21,7 @@ export default function EditUrlModal({
|
||||
const [password, setPassword] = useState<string | null>('');
|
||||
const [vanity, setVanity] = useState<string | null>(url?.vanity ?? null);
|
||||
const [destination, setDestination] = useState<string | null>(url?.destination ?? null);
|
||||
const [enabled, setEnabled] = useState<boolean>(url?.enabled ?? true);
|
||||
|
||||
const handleRemovePassword = async () => {
|
||||
if (!url.password) return;
|
||||
@@ -56,12 +57,14 @@ export default function EditUrlModal({
|
||||
password?: string;
|
||||
vanity?: string;
|
||||
destination?: string;
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||
if (password !== null) data['password'] = password?.trim();
|
||||
if (vanity !== null && vanity !== url.vanity) data['vanity'] = vanity?.trim();
|
||||
if (destination !== null && destination !== url.destination) data['destination'] = destination?.trim();
|
||||
if (enabled !== url.enabled) data['enabled'] = enabled;
|
||||
|
||||
const { error } = await fetchApi(`/api/user/urls/${url.id}`, 'PATCH', data);
|
||||
|
||||
@@ -118,6 +121,13 @@ export default function EditUrlModal({
|
||||
}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Enabled'
|
||||
description='Prevent or allow this URL from being visited.'
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{url.password ? (
|
||||
|
||||
@@ -20,13 +20,17 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group justify='space-between'>
|
||||
<Text fw={400}>
|
||||
<Anchor
|
||||
href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{url.vanity ?? url.code}
|
||||
</Anchor>
|
||||
{url.enabled ? (
|
||||
<Anchor
|
||||
href={formatRootUrl(config.urls.route, url.vanity ?? url.code)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{url.vanity ?? url.code}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{url.vanity ?? url.code}</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Menu withinPortal position='bottom-end' shadow='sm'>
|
||||
@@ -71,6 +75,9 @@ export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelecte
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Views:</b> {url.views.toLocaleString()}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Enabled:</b> {url.enabled ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed'>
|
||||
<b>Created:</b> <RelativeDate date={url.createdAt} />
|
||||
</Text>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
@@ -25,6 +27,7 @@ import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import UrlGridView from './views/UrlGridView';
|
||||
import UrlTableView from './views/UrlTableView';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
|
||||
export default function DashboardURLs() {
|
||||
const clipboard = useClipboard();
|
||||
@@ -37,12 +40,14 @@ export default function DashboardURLs() {
|
||||
vanity: string;
|
||||
maxViews: '' | number;
|
||||
password: string;
|
||||
enabled: boolean;
|
||||
}>({
|
||||
initialValues: {
|
||||
url: '',
|
||||
vanity: '',
|
||||
maxViews: '',
|
||||
password: '',
|
||||
enabled: true,
|
||||
},
|
||||
validate: {
|
||||
url: (value) => (value.length < 1 ? 'URL is required' : null),
|
||||
@@ -52,12 +57,20 @@ export default function DashboardURLs() {
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
if (URL.canParse(values.url) === false) return form.setFieldError('url', 'Invalid URL');
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/urls'], { url: string }>>(
|
||||
const { data, error } = await fetchApi<
|
||||
Extract<
|
||||
Response['/api/user/urls'],
|
||||
{
|
||||
url: string;
|
||||
} & Omit<Url, 'password'>
|
||||
>
|
||||
>(
|
||||
'/api/user/urls',
|
||||
'POST',
|
||||
{
|
||||
destination: values.url,
|
||||
vanity: values.vanity.trim() || null,
|
||||
enabled: values.enabled ?? true,
|
||||
},
|
||||
{
|
||||
...(values.maxViews !== '' && { 'x-zipline-max-views': String(values.maxViews) }),
|
||||
@@ -75,8 +88,10 @@ export default function DashboardURLs() {
|
||||
} else {
|
||||
setOpen(false);
|
||||
|
||||
const open = () => window.open(data?.url, '_blank');
|
||||
const open = () => (values.enabled ? window.open(data?.url, '_blank') : null);
|
||||
const copy = () => {
|
||||
if (!values.enabled) return;
|
||||
|
||||
clipboard.copy(data?.url);
|
||||
notifications.show({
|
||||
title: 'Copied URL to clipboard',
|
||||
@@ -96,16 +111,22 @@ export default function DashboardURLs() {
|
||||
children: (
|
||||
<Group justify='space-between'>
|
||||
<Group justify='left'>
|
||||
<Anchor component={Link} href={data?.url ?? ''}>
|
||||
{data?.url}
|
||||
</Anchor>
|
||||
{data?.enabled ? (
|
||||
<Anchor component={Link} href={data?.url ?? ''}>
|
||||
{data?.url}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text>{data?.url}</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group justify='right'>
|
||||
<Tooltip label='Open link in a new tab'>
|
||||
<ActionIcon onClick={() => open()} variant='filled'>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{data?.enabled && (
|
||||
<Tooltip label='Open link in a new tab'>
|
||||
<ActionIcon onClick={() => open()} variant='filled'>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label='Copy link to clipboard'>
|
||||
<ActionIcon onClick={() => copy()} variant='filled'>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
@@ -141,6 +162,12 @@ export default function DashboardURLs() {
|
||||
{...form.getInputProps('maxViews')}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Enabled'
|
||||
description='Allow or prevent this URL from being visited'
|
||||
{...form.getInputProps('enabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Protect your link with a password'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { ActionIcon, Anchor, Box, Group, TextInput, Tooltip } from '@mantine/core';
|
||||
import { ActionIcon, Anchor, Box, Checkbox, Group, TextInput, Tooltip } from '@mantine/core';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
@@ -247,11 +247,10 @@ export default function UrlTableView() {
|
||||
render: (url) => <RelativeDate date={url.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'similarity',
|
||||
title: 'Relevance',
|
||||
accessor: 'enabled',
|
||||
title: 'Enabled',
|
||||
sortable: true,
|
||||
render: (url) => (url.similarity ? url.similarity.toFixed(4) : 'N/A'),
|
||||
hidden: !searching,
|
||||
render: (url) => <Checkbox checked={url.enabled} />,
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
|
||||
@@ -75,10 +75,12 @@ export const getServerSideProps: GetServerSideProps<{
|
||||
destination: true,
|
||||
maxViews: true,
|
||||
views: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
if (!url) return { notFound: true };
|
||||
|
||||
if (!url.enabled) return { notFound: true };
|
||||
if (url.maxViews && url.views >= url.maxViews) {
|
||||
if (config.features.deleteOnMaxViews)
|
||||
await prisma.url.delete({
|
||||
|
||||
@@ -82,6 +82,7 @@ export default fastifyPlugin(
|
||||
...(req.body.password !== undefined && { password }),
|
||||
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
|
||||
...(req.body.destination !== undefined && { destination: req.body.destination }),
|
||||
...(req.body.enabled !== undefined && { enabled: req.body.enabled }),
|
||||
},
|
||||
omit: {
|
||||
password: true,
|
||||
@@ -102,11 +103,11 @@ export default fastifyPlugin(
|
||||
const url = await prisma.url.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!url) return res.notFound();
|
||||
if (url.userId !== req.user.id) return res.forbidden("You don't own this URL");
|
||||
|
||||
const deletedUrl = await prisma.url.delete({
|
||||
where: {
|
||||
|
||||
@@ -4,20 +4,20 @@ import { prisma } from '@/lib/db';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { log } from '@/lib/logger';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { onShorten } from '@/lib/webhooks';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
|
||||
export type ApiUserUrlsResponse =
|
||||
| Url[]
|
||||
| {
|
||||
| ({
|
||||
url: string;
|
||||
};
|
||||
} & Omit<Url, 'password'>);
|
||||
|
||||
type Body = {
|
||||
vanity?: string;
|
||||
destination: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type Headers = {
|
||||
@@ -50,7 +50,7 @@ export default fastifyPlugin(
|
||||
PATH,
|
||||
{ preHandler: [userMiddleware, rateLimit] },
|
||||
async (req, res) => {
|
||||
const { vanity, destination } = req.body;
|
||||
const { vanity, destination, enabled } = req.body;
|
||||
const noJson = !!req.headers['x-zipline-no-json'];
|
||||
|
||||
const countUrls = await prisma.url.count({
|
||||
@@ -97,6 +97,7 @@ export default fastifyPlugin(
|
||||
...(vanity && { vanity: vanity }),
|
||||
...(maxViews && { maxViews: maxViews }),
|
||||
...(password && { password: password }),
|
||||
...(enabled !== undefined && { enabled: enabled }),
|
||||
},
|
||||
omit: {
|
||||
password: true,
|
||||
@@ -133,6 +134,7 @@ export default fastifyPlugin(
|
||||
if (noJson) return res.type('text/plain').send(responseUrl);
|
||||
|
||||
return res.send({
|
||||
...url,
|
||||
url: responseUrl,
|
||||
});
|
||||
},
|
||||
@@ -149,18 +151,18 @@ export default fastifyPlugin(
|
||||
if (!searchThreshold.success) return res.badRequest('Invalid searchThreshold value');
|
||||
|
||||
if (searchQuery) {
|
||||
const similarityResult: Url[] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) AS similarity,
|
||||
*
|
||||
FROM "Url"
|
||||
WHERE
|
||||
word_similarity("${Prisma.raw(searchField.data)}", ${searchQuery}) > ${Prisma.raw(
|
||||
String(searchThreshold.data),
|
||||
)} OR
|
||||
"${Prisma.raw(searchField.data)}" ILIKE '${Prisma.sql`%${searchQuery}%`}' AND
|
||||
"userId" = ${req.user.id};
|
||||
`;
|
||||
const similarityResult = await prisma.url.findMany({
|
||||
where: {
|
||||
[searchField.data]: {
|
||||
mode: 'insensitive',
|
||||
contains: searchQuery,
|
||||
},
|
||||
userId: req.user.id,
|
||||
},
|
||||
omit: {
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(similarityResult);
|
||||
}
|
||||
@@ -169,6 +171,9 @@ export default fastifyPlugin(
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
omit: {
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(urls);
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function urlsRoute(
|
||||
},
|
||||
});
|
||||
if (!url) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
|
||||
if (!url.enabled) return req.server.nextServer.render404(req.raw, res.raw, parsedUrl);
|
||||
|
||||
if (url.maxViews && url.views >= url.maxViews) {
|
||||
if (config.features.deleteOnMaxViews) {
|
||||
|
||||
Reference in New Issue
Block a user