From 6e2da52f773705d8c2e5b294a0463d51a5eb0117 Mon Sep 17 00:00:00 2001 From: diced Date: Mon, 3 Nov 2025 16:37:12 -0800 Subject: [PATCH] feat: actions when viewing other user files (#918) --- .../file/DashboardFile/FileModal.tsx | 4 +- src/components/pages/files/bulk.tsx | 23 ++--- .../pages/files/views/FileTable.tsx | 87 +++++++++++-------- .../routes/api/user/files/[id]/index.ts | 19 ++-- .../routes/api/user/files/transaction.ts | 66 ++++++++++---- 5 files changed, 130 insertions(+), 69 deletions(-) diff --git a/src/components/file/DashboardFile/FileModal.tsx b/src/components/file/DashboardFile/FileModal.tsx index 26557e9d..2c416be5 100755 --- a/src/components/file/DashboardFile/FileModal.tsx +++ b/src/components/file/DashboardFile/FileModal.tsx @@ -88,11 +88,13 @@ export default function FileModal({ setOpen, file, reduce, + user, }: { open: boolean; setOpen: (open: boolean) => void; file?: File | null; reduce?: boolean; + user?: string; }) { const clipboard = useClipboard(); const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion); @@ -226,7 +228,7 @@ export default function FileModal({ )} - {!reduce && ( + {!reduce && !user && ( diff --git a/src/components/pages/files/bulk.tsx b/src/components/pages/files/bulk.tsx index 5bf58130..276f26a2 100755 --- a/src/components/pages/files/bulk.tsx +++ b/src/components/pages/files/bulk.tsx @@ -69,20 +69,23 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[] }); } -export async function bulkFavorite(ids: string[]) { +export async function bulkFavorite(ids: string[], favorite: boolean) { + const text = favorite ? 'favorite' : 'unfavorite'; + const textcaps = favorite ? 'Favorite' : 'Unfavorite'; + modals.openConfirmModal({ centered: true, - title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`, - children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`, + title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`, + children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`, labels: { cancel: 'Cancel', - confirm: 'Favorite', + confirm: `${textcaps}`, }, confirmProps: { color: 'yellow' }, onConfirm: async () => { notifications.show({ - title: 'Favoriting files', - message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`, + title: `${textcaps}ing files`, + message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`, color: 'yellow', loading: true, id: 'bulk-favorite', @@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) { { files: ids, - favorite: true, + favorite, }, ); if (error) { notifications.update({ - title: 'Error while favoriting files', + title: 'Error while modifying files', message: error.error, color: 'red', icon: <IconStarsOff size='1rem' />, @@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) { }); } else if (data) { notifications.update({ - title: 'Favorited files', - message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`, + title: `${textcaps}d files`, + message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`, color: 'yellow', icon: <IconStarsFilled size='1rem' />, id: 'bulk-favorite', diff --git a/src/components/pages/files/views/FileTable.tsx b/src/components/pages/files/views/FileTable.tsx index f4a8ca00..5bf5e5e7 100755 --- a/src/components/pages/files/views/FileTable.tsx +++ b/src/components/pages/files/views/FileTable.tsx @@ -388,6 +388,8 @@ export default function FileTable({ } }, [searchField]); + const unfavoriteAll = selectedFiles.every((file) => file.favorite); + return ( <> <FileModal @@ -396,6 +398,7 @@ export default function FileTable({ if (!open) setSelectedFile(null); }} file={selectedFile} + user={id} /> <TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} /> @@ -427,48 +430,56 @@ export default function FileTable({ variant='outline' color='yellow' leftSection={<IconStar size='1rem' />} - onClick={() => bulkFavorite(selectedFiles.map((x) => x.id))} + onClick={() => + bulkFavorite( + selectedFiles.map((x) => x.id), + !unfavoriteAll, + ) + } > - Favorite {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''} + {unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file + {selectedFiles.length > 1 ? 's' : ''} </Button> - <Combobox - store={combobox} - withinPortal={false} - onOptionSubmit={(value) => handleAddFolder(value)} - > - <Combobox.Target> - <InputBase - rightSection={<Combobox.Chevron />} - value={folderSearch} - onChange={(event) => { - combobox.openDropdown(); - combobox.updateSelectedOptionIndex(); - setFolderSearch(event.currentTarget.value); - }} - onClick={() => combobox.openDropdown()} - onFocus={() => combobox.openDropdown()} - onBlur={() => { - combobox.closeDropdown(); - setFolderSearch(folderSearch || ''); - }} - placeholder='Add to folder...' - rightSectionPointerEvents='none' - /> - </Combobox.Target> + {!id && ( + <Combobox + store={combobox} + withinPortal={false} + onOptionSubmit={(value) => handleAddFolder(value)} + > + <Combobox.Target> + <InputBase + rightSection={<Combobox.Chevron />} + value={folderSearch} + onChange={(event) => { + combobox.openDropdown(); + combobox.updateSelectedOptionIndex(); + setFolderSearch(event.currentTarget.value); + }} + onClick={() => combobox.openDropdown()} + onFocus={() => combobox.openDropdown()} + onBlur={() => { + combobox.closeDropdown(); + setFolderSearch(folderSearch || ''); + }} + placeholder='Add to folder...' + rightSectionPointerEvents='none' + /> + </Combobox.Target> - <Combobox.Dropdown> - <Combobox.Options> - {folders - ?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim())) - .map((f) => ( - <Combobox.Option value={f.id} key={f.id}> - {f.name} - </Combobox.Option> - ))} - </Combobox.Options> - </Combobox.Dropdown> - </Combobox> + <Combobox.Dropdown> + <Combobox.Options> + {folders + ?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim())) + .map((f) => ( + <Combobox.Option value={f.id} key={f.id}> + {f.name} + </Combobox.Option> + ))} + </Combobox.Options> + </Combobox.Dropdown> + </Combobox> + )} </Group> <Button diff --git a/src/server/routes/api/user/files/[id]/index.ts b/src/server/routes/api/user/files/[id]/index.ts index a28e9ee6..844b7de5 100755 --- a/src/server/routes/api/user/files/[id]/index.ts +++ b/src/server/routes/api/user/files/[id]/index.ts @@ -7,6 +7,7 @@ import { File, fileSelect } from '@/lib/db/models/file'; import { log } from '@/lib/logger'; import { userMiddleware } from '@/server/middleware/user'; import fastifyPlugin from 'fastify-plugin'; +import { canInteract } from '@/lib/role'; export type ApiUserFilesIdResponse = File; @@ -33,12 +34,13 @@ export default fastifyPlugin( const file = await prisma.file.findFirst({ where: { OR: [{ id: req.params.id }, { name: req.params.id }], - userId: req.user.id, }, - select: fileSelect, + select: { User: true, ...fileSelect }, }); if (!file) return res.notFound(); + if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound(); + return res.send(file); }); @@ -49,12 +51,13 @@ export default fastifyPlugin( const file = await prisma.file.findFirst({ where: { OR: [{ id: req.params.id }, { name: req.params.id }], - userId: req.user.id, }, - select: fileSelect, + select: { User: true, ...fileSelect }, }); if (!file) return res.notFound(); + if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound(); + const data: Prisma.FileUpdateInput = {}; if (req.body.favorite !== undefined) data.favorite = req.body.favorite; @@ -126,6 +129,7 @@ export default fastifyPlugin( logger.info(`${req.user.username} updated file ${newFile.name}`, { updated: Object.keys(req.body), id: newFile.id, + owner: file.User?.id, }); return res.send(newFile); @@ -135,11 +139,15 @@ export default fastifyPlugin( const file = await prisma.file.findFirst({ where: { OR: [{ id: req.params.id }, { name: req.params.id }], - userId: req.user.id, + }, + include: { + User: true, }, }); if (!file) return res.notFound(); + if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound(); + const deletedFile = await prisma.file.delete({ where: { id: file.id, @@ -151,6 +159,7 @@ export default fastifyPlugin( logger.info(`${req.user.username} deleted file ${deletedFile.name}`, { size: bytes(deletedFile.size), + owner: file.User?.id, }); return res.send(deletedFile); diff --git a/src/server/routes/api/user/files/transaction.ts b/src/server/routes/api/user/files/transaction.ts index 01aba53f..c8d8b782 100755 --- a/src/server/routes/api/user/files/transaction.ts +++ b/src/server/routes/api/user/files/transaction.ts @@ -2,6 +2,8 @@ import { datasource } from '@/lib/datasource'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; +import { canInteract } from '@/lib/role'; +import { Role } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import fastifyPlugin from 'fastify-plugin'; @@ -22,6 +24,18 @@ type Body = { const logger = log('api').c('user').c('files').c('transaction'); +function checkInteraction(current: Role, roles: Role[]) { + const indices: number[] = []; + + for (let i = 0; i !== roles.length; ++i) { + if (!canInteract(current, roles[i])) { + indices.push(i); + } + } + + return indices; +} + export const PATH = '/api/user/files/transaction'; export default fastifyPlugin( (server, _, done) => { @@ -34,14 +48,28 @@ export default fastifyPlugin( if (!files || !files.length) return res.badRequest('Cannot process transaction without files'); if (typeof favorite === 'boolean') { + const toFavoriteFiles = await prisma.file.findMany({ + where: { + id: { in: files }, + }, + include: { + User: true, + }, + }); + + const invalids = checkInteraction( + req.user.role, + toFavoriteFiles.map((f) => f.User?.role ?? 'USER'), + ); + if (invalids.length > 0) + return res.forbidden(`You don't have the permission to modify files[${invalids.join(', ')}]`); + const resp = await prisma.file.updateMany({ where: { id: { in: files, }, - userId: req.user.id, }, - data: { favorite: favorite, }, @@ -51,6 +79,7 @@ export default fastifyPlugin( logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, { user: req.user.id, + owners: toFavoriteFiles.map((f) => f.userId), }); return res.send(resp); @@ -108,21 +137,28 @@ export default fastifyPlugin( files: files.length, }); - if (delete_datasourceFiles) { - const dFiles = await prisma.file.findMany({ - where: { - id: { - in: files, - }, - userId: req.user.id, - }, - }); + const toDeleteFiles = await prisma.file.findMany({ + where: { + id: { in: files }, + }, + include: { + User: true, + }, + }); - for (let i = 0; i !== dFiles.length; ++i) { - await datasource.delete(dFiles[i].name); + const invalids = checkInteraction( + req.user.role, + toDeleteFiles.map((f) => f.User?.role ?? 'USER'), + ); + if (invalids.length > 0) + return res.forbidden(`You don't have the permission to delete files[${invalids.join(', ')}]`); + + if (delete_datasourceFiles) { + for (let i = 0; i !== toDeleteFiles.length; ++i) { + await datasource.delete(toDeleteFiles[i].name); } - logger.info(`${req.user.username} deleted ${dFiles.length} files from datasource`, { + logger.info(`${req.user.username} deleted ${toDeleteFiles.length} files from datasource`, { user: req.user.id, }); } @@ -132,7 +168,6 @@ export default fastifyPlugin( id: { in: files, }, - userId: req.user.id, }, }); @@ -140,6 +175,7 @@ export default fastifyPlugin( logger.info(`${req.user.username} deleted ${resp.count} files`, { user: req.user.id, + owners: toDeleteFiles.map((f) => f.userId), }); return res.send(resp);