feat: actions when viewing other user files (#918)

This commit is contained in:
diced
2025-11-03 16:37:12 -08:00
parent 04b27a2dee
commit 6e2da52f77
5 changed files with 130 additions and 69 deletions

View File

@@ -88,11 +88,13 @@ export default function FileModal({
setOpen, setOpen,
file, file,
reduce, reduce,
user,
}: { }: {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
file?: File | null; file?: File | null;
reduce?: boolean; reduce?: boolean;
user?: string;
}) { }) {
const clipboard = useClipboard(); const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion); const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -226,7 +228,7 @@ export default function FileModal({
)} )}
</SimpleGrid> </SimpleGrid>
{!reduce && ( {!reduce && !user && (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'> <SimpleGrid cols={{ base: 1, md: 2 }} spacing='md' my='xs'>
<Box> <Box>
<Title order={4} mt='lg' mb='xs'> <Title order={4} mt='lg' mb='xs'>

View File

@@ -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({ modals.openConfirmModal({
centered: true, centered: true,
title: `Favorite ${ids.length} file${ids.length === 1 ? '' : 's'}?`, title: `${textcaps} ${ids.length} file${ids.length === 1 ? '' : 's'}?`,
children: `You are about to favorite ${ids.length} file${ids.length === 1 ? '' : 's'}.`, children: `You are about to ${text} ${ids.length} file${ids.length === 1 ? '' : 's'}.`,
labels: { labels: {
cancel: 'Cancel', cancel: 'Cancel',
confirm: 'Favorite', confirm: `${textcaps}`,
}, },
confirmProps: { color: 'yellow' }, confirmProps: { color: 'yellow' },
onConfirm: async () => { onConfirm: async () => {
notifications.show({ notifications.show({
title: 'Favoriting files', title: `${textcaps}ing files`,
message: `Favoriting ${ids.length} file${ids.length === 1 ? '' : 's'}`, message: `${textcaps}ing ${ids.length} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow', color: 'yellow',
loading: true, loading: true,
id: 'bulk-favorite', id: 'bulk-favorite',
@@ -96,13 +99,13 @@ export async function bulkFavorite(ids: string[]) {
{ {
files: ids, files: ids,
favorite: true, favorite,
}, },
); );
if (error) { if (error) {
notifications.update({ notifications.update({
title: 'Error while favoriting files', title: 'Error while modifying files',
message: error.error, message: error.error,
color: 'red', color: 'red',
icon: <IconStarsOff size='1rem' />, icon: <IconStarsOff size='1rem' />,
@@ -112,8 +115,8 @@ export async function bulkFavorite(ids: string[]) {
}); });
} else if (data) { } else if (data) {
notifications.update({ notifications.update({
title: 'Favorited files', title: `${textcaps}d files`,
message: `Favorited ${data.count} file${ids.length === 1 ? '' : 's'}`, message: `${textcaps}d ${data.count} file${ids.length === 1 ? '' : 's'}`,
color: 'yellow', color: 'yellow',
icon: <IconStarsFilled size='1rem' />, icon: <IconStarsFilled size='1rem' />,
id: 'bulk-favorite', id: 'bulk-favorite',

View File

@@ -388,6 +388,8 @@ export default function FileTable({
} }
}, [searchField]); }, [searchField]);
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
return ( return (
<> <>
<FileModal <FileModal
@@ -396,6 +398,7 @@ export default function FileTable({
if (!open) setSelectedFile(null); if (!open) setSelectedFile(null);
}} }}
file={selectedFile} file={selectedFile}
user={id}
/> />
<TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} /> <TableEditModal opened={tableEdit.open} onCLose={() => tableEdit.setOpen(false)} />
@@ -427,48 +430,56 @@ export default function FileTable({
variant='outline' variant='outline'
color='yellow' color='yellow'
leftSection={<IconStar size='1rem' />} 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> </Button>
<Combobox {!id && (
store={combobox} <Combobox
withinPortal={false} store={combobox}
onOptionSubmit={(value) => handleAddFolder(value)} withinPortal={false}
> onOptionSubmit={(value) => handleAddFolder(value)}
<Combobox.Target> >
<InputBase <Combobox.Target>
rightSection={<Combobox.Chevron />} <InputBase
value={folderSearch} rightSection={<Combobox.Chevron />}
onChange={(event) => { value={folderSearch}
combobox.openDropdown(); onChange={(event) => {
combobox.updateSelectedOptionIndex(); combobox.openDropdown();
setFolderSearch(event.currentTarget.value); combobox.updateSelectedOptionIndex();
}} setFolderSearch(event.currentTarget.value);
onClick={() => combobox.openDropdown()} }}
onFocus={() => combobox.openDropdown()} onClick={() => combobox.openDropdown()}
onBlur={() => { onFocus={() => combobox.openDropdown()}
combobox.closeDropdown(); onBlur={() => {
setFolderSearch(folderSearch || ''); combobox.closeDropdown();
}} setFolderSearch(folderSearch || '');
placeholder='Add to folder...' }}
rightSectionPointerEvents='none' placeholder='Add to folder...'
/> rightSectionPointerEvents='none'
</Combobox.Target> />
</Combobox.Target>
<Combobox.Dropdown> <Combobox.Dropdown>
<Combobox.Options> <Combobox.Options>
{folders {folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim())) ?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
.map((f) => ( .map((f) => (
<Combobox.Option value={f.id} key={f.id}> <Combobox.Option value={f.id} key={f.id}>
{f.name} {f.name}
</Combobox.Option> </Combobox.Option>
))} ))}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
)}
</Group> </Group>
<Button <Button

View File

@@ -7,6 +7,7 @@ import { File, fileSelect } from '@/lib/db/models/file';
import { log } from '@/lib/logger'; import { log } from '@/lib/logger';
import { userMiddleware } from '@/server/middleware/user'; import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin'; import fastifyPlugin from 'fastify-plugin';
import { canInteract } from '@/lib/role';
export type ApiUserFilesIdResponse = File; export type ApiUserFilesIdResponse = File;
@@ -33,12 +34,13 @@ export default fastifyPlugin(
const file = await prisma.file.findFirst({ const file = await prisma.file.findFirst({
where: { where: {
OR: [{ id: req.params.id }, { name: req.params.id }], 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 (!file) return res.notFound();
if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound();
return res.send(file); return res.send(file);
}); });
@@ -49,12 +51,13 @@ export default fastifyPlugin(
const file = await prisma.file.findFirst({ const file = await prisma.file.findFirst({
where: { where: {
OR: [{ id: req.params.id }, { name: req.params.id }], 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 (!file) return res.notFound();
if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound();
const data: Prisma.FileUpdateInput = {}; const data: Prisma.FileUpdateInput = {};
if (req.body.favorite !== undefined) data.favorite = req.body.favorite; 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}`, { logger.info(`${req.user.username} updated file ${newFile.name}`, {
updated: Object.keys(req.body), updated: Object.keys(req.body),
id: newFile.id, id: newFile.id,
owner: file.User?.id,
}); });
return res.send(newFile); return res.send(newFile);
@@ -135,11 +139,15 @@ export default fastifyPlugin(
const file = await prisma.file.findFirst({ const file = await prisma.file.findFirst({
where: { where: {
OR: [{ id: req.params.id }, { name: req.params.id }], OR: [{ id: req.params.id }, { name: req.params.id }],
userId: req.user.id, },
include: {
User: true,
}, },
}); });
if (!file) return res.notFound(); if (!file) return res.notFound();
if (!canInteract(req.user.role, file.User?.role ?? 'USER')) return res.notFound();
const deletedFile = await prisma.file.delete({ const deletedFile = await prisma.file.delete({
where: { where: {
id: file.id, id: file.id,
@@ -151,6 +159,7 @@ export default fastifyPlugin(
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, { logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
size: bytes(deletedFile.size), size: bytes(deletedFile.size),
owner: file.User?.id,
}); });
return res.send(deletedFile); return res.send(deletedFile);

View File

@@ -2,6 +2,8 @@ import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { log } from '@/lib/logger'; import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits'; import { secondlyRatelimit } from '@/lib/ratelimits';
import { canInteract } from '@/lib/role';
import { Role } from '@/prisma/client';
import { userMiddleware } from '@/server/middleware/user'; import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin'; import fastifyPlugin from 'fastify-plugin';
@@ -22,6 +24,18 @@ type Body = {
const logger = log('api').c('user').c('files').c('transaction'); 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 const PATH = '/api/user/files/transaction';
export default fastifyPlugin( export default fastifyPlugin(
(server, _, done) => { (server, _, done) => {
@@ -34,14 +48,28 @@ export default fastifyPlugin(
if (!files || !files.length) return res.badRequest('Cannot process transaction without files'); if (!files || !files.length) return res.badRequest('Cannot process transaction without files');
if (typeof favorite === 'boolean') { 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({ const resp = await prisma.file.updateMany({
where: { where: {
id: { id: {
in: files, in: files,
}, },
userId: req.user.id,
}, },
data: { data: {
favorite: favorite, favorite: favorite,
}, },
@@ -51,6 +79,7 @@ export default fastifyPlugin(
logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, { logger.info(`${req.user.username} ${favorite ? 'favorited' : 'unfavorited'} ${resp.count} files`, {
user: req.user.id, user: req.user.id,
owners: toFavoriteFiles.map((f) => f.userId),
}); });
return res.send(resp); return res.send(resp);
@@ -108,21 +137,28 @@ export default fastifyPlugin(
files: files.length, files: files.length,
}); });
if (delete_datasourceFiles) { const toDeleteFiles = await prisma.file.findMany({
const dFiles = await prisma.file.findMany({ where: {
where: { id: { in: files },
id: { },
in: files, include: {
}, User: true,
userId: req.user.id, },
}, });
});
for (let i = 0; i !== dFiles.length; ++i) { const invalids = checkInteraction(
await datasource.delete(dFiles[i].name); 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, user: req.user.id,
}); });
} }
@@ -132,7 +168,6 @@ export default fastifyPlugin(
id: { id: {
in: files, in: files,
}, },
userId: req.user.id,
}, },
}); });
@@ -140,6 +175,7 @@ export default fastifyPlugin(
logger.info(`${req.user.username} deleted ${resp.count} files`, { logger.info(`${req.user.username} deleted ${resp.count} files`, {
user: req.user.id, user: req.user.id,
owners: toDeleteFiles.map((f) => f.userId),
}); });
return res.send(resp); return res.send(resp);