From 129ba0cdead75aba50ed31df58118a63774543c0 Mon Sep 17 00:00:00 2001 From: diced Date: Tue, 12 Nov 2024 22:18:11 -0800 Subject: [PATCH] feat: edit urls --- .../DashboardFile/EditFileDetailsModal.tsx | 9 +- .../pages/files/views/FileTable.tsx | 2 +- .../pages/folders/views/FolderTableView.tsx | 2 +- .../pages/invites/views/InviteTableView.tsx | 2 +- src/components/pages/urls/EditUrlModal.tsx | 157 ++++++++++++++++++ src/components/pages/urls/UrlCard.tsx | 7 +- .../pages/urls/views/UrlGridView.tsx | 7 +- .../pages/urls/views/UrlTableView.tsx | 19 ++- .../pages/users/views/UserTableView.tsx | 2 +- src/lib/parser/index.ts | 2 +- src/lib/webhooks/discord.ts | 12 +- src/server/routes/api/user/urls/[id]/index.ts | 72 ++++++++ src/server/routes/api/user/urls/index.ts | 3 + 13 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 src/components/pages/urls/EditUrlModal.tsx diff --git a/src/components/file/DashboardFile/EditFileDetailsModal.tsx b/src/components/file/DashboardFile/EditFileDetailsModal.tsx index 1c238de04..3073c7939 100755 --- a/src/components/file/DashboardFile/EditFileDetailsModal.tsx +++ b/src/components/file/DashboardFile/EditFileDetailsModal.tsx @@ -47,6 +47,7 @@ export default function EditFileDetailsModal({ mutateFiles(); } }; + const handleSave = async () => { const data: { maxViews?: number; @@ -95,7 +96,7 @@ export default function EditFileDetailsModal({ setMaxViews(value === '' ? null : Number(value))} @@ -104,7 +105,7 @@ export default function EditFileDetailsModal({ setOriginalName(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim()) @@ -140,7 +141,7 @@ export default function EditFileDetailsModal({ ) : ( @@ -153,7 +154,7 @@ export default function EditFileDetailsModal({ diff --git a/src/components/pages/files/views/FileTable.tsx b/src/components/pages/files/views/FileTable.tsx index d5c046b0c..c5729de6b 100755 --- a/src/components/pages/files/views/FileTable.tsx +++ b/src/components/pages/files/views/FileTable.tsx @@ -430,7 +430,7 @@ export default function FileTable({ id }: { id?: string }) { { accessor: 'actions', textAlign: 'right', - width: 180, + width: 45 * 4, render: (file) => ( diff --git a/src/components/pages/folders/views/FolderTableView.tsx b/src/components/pages/folders/views/FolderTableView.tsx index 28565eda6..3de90882b 100755 --- a/src/components/pages/folders/views/FolderTableView.tsx +++ b/src/components/pages/folders/views/FolderTableView.tsx @@ -86,7 +86,7 @@ export default function FolderTableView() { }, { accessor: 'actions', - width: 170, + width: 45 * 4, render: (folder) => ( diff --git a/src/components/pages/invites/views/InviteTableView.tsx b/src/components/pages/invites/views/InviteTableView.tsx index d2af3d804..09e499f85 100755 --- a/src/components/pages/invites/views/InviteTableView.tsx +++ b/src/components/pages/invites/views/InviteTableView.tsx @@ -87,7 +87,7 @@ export default function InviteTableView() { }, { accessor: 'actions', - width: 100, + width: 45 * 2, render: (invite) => ( diff --git a/src/components/pages/urls/EditUrlModal.tsx b/src/components/pages/urls/EditUrlModal.tsx new file mode 100644 index 000000000..60d7cdca2 --- /dev/null +++ b/src/components/pages/urls/EditUrlModal.tsx @@ -0,0 +1,157 @@ +import { Url } from '@/lib/db/models/url'; +import { fetchApi } from '@/lib/fetchApi'; +import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput, Title } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react'; +import { useState } from 'react'; +import { mutate } from 'swr'; + +export default function EditUrlModal({ + url, + onClose, + open, +}: { + open: boolean; + url: Url | null; + onClose: () => void; +}) { + if (!url) return null; + + const [maxViews, setMaxViews] = useState(url?.maxViews ?? null); + const [password, setPassword] = useState(''); + const [vanity, setVanity] = useState(url?.vanity ?? null); + const [destination, setDestination] = useState(url?.destination ?? null); + + const handleRemovePassword = async () => { + if (!url.password) return; + + const { error } = await fetchApi(`/api/user/urls/${url.id}`, 'PATCH', { + password: null, + }); + + if (error) { + showNotification({ + title: 'Failed to remove password...', + message: error.error, + color: 'red', + icon: , + }); + } else { + showNotification({ + title: 'Password removed!', + message: 'The password has been removed from the URL.', + color: 'green', + icon: , + }); + + onClose(); + mutate('/api/user/urls'); + mutate({ key: '/api/user/urls' }); + } + }; + + const handleSave = async () => { + const data: { + maxViews?: number; + password?: string; + vanity?: string; + destination?: string; + } = {}; + + 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(); + + const { error } = await fetchApi(`/api/user/urls/${url.id}`, 'PATCH', data); + + if (error) { + showNotification({ + title: 'Failed to save changes...', + message: error.error, + color: 'red', + icon: , + }); + } else { + showNotification({ + title: 'Changes saved!', + message: 'The changes have been saved successfully.', + color: 'green', + icon: , + }); + + onClose(); + mutate('/api/user/urls'); + mutate({ key: '/api/user/urls' }); + } + }; + + return ( + Editing "{url.vanity ?? url.code}"} + opened={open} + onClose={onClose} + > + + setMaxViews(value === '' ? null : Number(value))} + min={0} + leftSection={} + /> + + + setVanity(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim()) + } + /> + + + setDestination(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim()) + } + /> + + + + {url.password ? ( + + ) : ( + + setPassword(event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim()) + } + leftSection={} + /> + )} + + + + + + + ); +} diff --git a/src/components/pages/urls/UrlCard.tsx b/src/components/pages/urls/UrlCard.tsx index 2696b5687..96eaa3723 100755 --- a/src/components/pages/urls/UrlCard.tsx +++ b/src/components/pages/urls/UrlCard.tsx @@ -4,11 +4,11 @@ import { Url } from '@/lib/db/models/url'; import { formatRootUrl } from '@/lib/url'; import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text, Tooltip } from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; -import { IconCopy, IconDots, IconTrashFilled } from '@tabler/icons-react'; +import { IconCopy, IconDots, IconPencil, IconTrashFilled } from '@tabler/icons-react'; import { copyUrl, deleteUrl } from './actions'; import { useSettingsStore } from '@/lib/store/settings'; -export default function UserCard({ url }: { url: Url }) { +export default function UserCard({ url, setSelectedUrl }: { url: Url; setSelectedUrl: (url: Url) => void }) { const config = useConfig(); const clipboard = useClipboard(); @@ -51,6 +51,9 @@ export default function UserCard({ url }: { url: Url }) { > Copy destination + } onClick={() => setSelectedUrl(url)}> + Edit + } color='red' diff --git a/src/components/pages/urls/views/UrlGridView.tsx b/src/components/pages/urls/views/UrlGridView.tsx index a535d172f..195835f17 100755 --- a/src/components/pages/urls/views/UrlGridView.tsx +++ b/src/components/pages/urls/views/UrlGridView.tsx @@ -2,14 +2,19 @@ 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 { useState } from 'react'; import useSWR from 'swr'; +import EditUrlModal from '../EditUrlModal'; import UrlCard from '../UrlCard'; export default function UrlGridView() { const { data: urls, isLoading } = useSWR>('/api/user/urls'); + const [selectedUrl, setSelectedUrl] = useState(null); return ( <> + setSelectedUrl(null)} open={!!selectedUrl} /> + {isLoading ? ( @@ -25,7 +30,7 @@ export default function UrlGridView() { }} pos='relative' > - {urls?.map((url) => )} + {urls?.map((url) => )} ) : ( diff --git a/src/components/pages/urls/views/UrlTableView.tsx b/src/components/pages/urls/views/UrlTableView.tsx index 35a595a18..012afb7c3 100755 --- a/src/components/pages/urls/views/UrlTableView.tsx +++ b/src/components/pages/urls/views/UrlTableView.tsx @@ -6,11 +6,12 @@ import { DataTable, DataTableSortStatus } from 'mantine-datatable'; import { useEffect, useReducer, useState } from 'react'; import useSWR from 'swr'; import { copyUrl, deleteUrl } from '../actions'; -import { IconCopy, IconTrashFilled } from '@tabler/icons-react'; +import { IconCopy, IconPencil, IconTrashFilled } from '@tabler/icons-react'; import { useConfig } from '@/components/ConfigProvider'; import { useClipboard } from '@mantine/hooks'; import { useSettingsStore } from '@/lib/store/settings'; import { formatRootUrl } from '@/lib/url'; +import EditUrlModal from '../EditUrlModal'; const NAMES = { code: 'Code', @@ -131,6 +132,8 @@ export default function UrlTableView() { searchQuery.vanity.trim() !== '' || searchQuery.destination.trim() !== ''; + const [selectedUrl, setSelectedUrl] = useState(null); + useEffect(() => { if (data) { const sorted = data.sort((a, b) => { @@ -162,6 +165,8 @@ export default function UrlTableView() { return ( <> + setSelectedUrl(null)} open={!!selectedUrl} /> + ( @@ -263,6 +268,16 @@ export default function UrlTableView() { + + { + e.stopPropagation(); + setSelectedUrl(url); + }} + > + + + ( diff --git a/src/lib/parser/index.ts b/src/lib/parser/index.ts index a3a357280..94003af85 100755 --- a/src/lib/parser/index.ts +++ b/src/lib/parser/index.ts @@ -7,7 +7,7 @@ import { ParseValueMetrics } from './metrics'; export type ParseValue = { file?: File; - url?: Url; + url?: Partial; user?: User | Omit; link?: { diff --git a/src/lib/webhooks/discord.ts b/src/lib/webhooks/discord.ts index f2f47ff36..ce2a6b61d 100755 --- a/src/lib/webhooks/discord.ts +++ b/src/lib/webhooks/discord.ts @@ -75,7 +75,7 @@ export function parseContent( export function buildResponse( content: ReturnType, file?: File, - url?: Url, + url?: Partial, ): WebhooksExecuteBody | null { if (!content) return null; if (!file && !url) return null; @@ -138,7 +138,15 @@ export async function onUpload({ user, file, link }: { user: User; file: File; l return; } -export async function onShorten({ user, url, link }: { user: User; url: Url; link: ParseValue['link'] }) { +export async function onShorten({ + user, + url, + link, +}: { + user: User; + url: Partial; + link: ParseValue['link']; +}) { if (!config.discord?.onShorten) return logger.debug('no onShorten config, no webhook executed'); if (!config.discord?.webhookUrl || !config.discord?.onShorten?.webhookUrl) return logger.debug('no webhookUrl config, no webhook executed'); diff --git a/src/server/routes/api/user/urls/[id]/index.ts b/src/server/routes/api/user/urls/[id]/index.ts index 8037da9ad..4f587e36f 100755 --- a/src/server/routes/api/user/urls/[id]/index.ts +++ b/src/server/routes/api/user/urls/[id]/index.ts @@ -1,5 +1,7 @@ +import { hashPassword } from '@/lib/crypto'; import { prisma } from '@/lib/db'; import { Url } from '@/lib/db/models/url'; +import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; import fastifyPlugin from 'fastify-plugin'; @@ -9,6 +11,8 @@ type Params = { id: string; }; +const logger = log('api').c('user').c('urls').c('[id]'); + export const PATH = '/api/user/urls/:id'; export default fastifyPlugin( (server, _, done) => { @@ -19,6 +23,9 @@ export default fastifyPlugin( where: { id: id, }, + omit: { + password: true, + }, }); if (!url) return res.notFound(); @@ -27,6 +34,68 @@ export default fastifyPlugin( return res.send(url); }); + server.patch<{ Body: Partial; Params: Params }>( + PATH, + { preHandler: [userMiddleware] }, + async (req, res) => { + const { id } = req.params; + + const url = await prisma.url.findFirst({ + where: { + id: id, + }, + }); + + if (!url) return res.notFound(); + if (url.userId !== req.user.id) return res.forbidden(); + + let password: string | null | undefined = undefined; + if (req.body.password !== undefined) { + if (req.body.password === null || req.body.password === '') { + password = null; + } else if (typeof req.body.password === 'string') { + password = await hashPassword(req.body.password); + } else { + return res.badRequest('password must be a string'); + } + } + + if (req.body.vanity) { + const existingUrl = await prisma.url.findFirst({ + where: { + vanity: req.body.vanity, + }, + }); + + if (existingUrl) return res.badRequest('vanity already exists'); + } + + if (req.body.maxViews !== undefined && req.body.maxViews! < 0) + return res.badRequest('maxViews must be >= 0'); + + const updatedUrl = await prisma.url.update({ + where: { + id: id, + }, + data: { + ...(req.body.vanity !== undefined && { vanity: req.body.vanity }), + ...(req.body.password !== undefined && { password }), + ...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }), + ...(req.body.destination !== undefined && { destination: req.body.destination }), + }, + omit: { + password: true, + }, + }); + + logger.info(`${req.user.username} updated URL ${updatedUrl.id}`, { + body: req.body, + }); + + return res.send(updatedUrl); + }, + ); + server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => { const { id } = req.params; @@ -43,6 +112,9 @@ export default fastifyPlugin( where: { id: id, }, + omit: { + password: true, + }, }); return res.send(deletedUrl); diff --git a/src/server/routes/api/user/urls/index.ts b/src/server/routes/api/user/urls/index.ts index 2aaad9f7e..76b88f304 100755 --- a/src/server/routes/api/user/urls/index.ts +++ b/src/server/routes/api/user/urls/index.ts @@ -98,6 +98,9 @@ export default fastifyPlugin( ...(maxViews && { maxViews: maxViews }), ...(password && { password: password }), }, + omit: { + password: true, + }, }); let domain;