feat: anonymous folder uploads

This commit is contained in:
diced
2025-03-03 22:26:38 -08:00
parent ba144ab58c
commit 20b781709f
14 changed files with 151 additions and 35 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "allowUploads" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -284,6 +284,7 @@ model Folder {
name String
public Boolean @default(false)
allowUploads Boolean @default(false)
files File[]

View File

@@ -29,7 +29,7 @@ import { uploadPartialFiles } from '../uploadPartialFiles';
import { humanizeDuration } from '@/lib/relativeTime';
import { useShallow } from 'zustand/shallow';
export default function UploadFile() {
export default function UploadFile({ title, folder }: { title?: string; folder?: string }) {
const theme = useMantineTheme();
const colorScheme = useColorScheme();
const clipboard = useClipboard();
@@ -85,6 +85,7 @@ export default function UploadFile() {
options,
ephemeral,
config,
folder,
});
} else {
const size = aggSize();
@@ -111,6 +112,7 @@ export default function UploadFile() {
clearEphemeral,
options,
ephemeral,
folder,
});
}
};
@@ -126,13 +128,15 @@ export default function UploadFile() {
return (
<>
<Group gap='sm'>
<Title order={1}>Upload files</Title>
<Title order={1}>{title ?? 'Upload files'}</Title>
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>
{!folder && (
<Tooltip label='View your files'>
<ActionIcon component={Link} href='/dashboard/files' variant='outline' radius='sm'>
<IconFiles size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Dropzone
@@ -212,7 +216,7 @@ export default function UploadFile() {
</Grid>
<Group justify='right' gap='sm' my='md'>
<UploadOptionsButton numFiles={files.length} />
<UploadOptionsButton folder={folder} numFiles={files.length} />
<Button
variant='outline'

View File

@@ -36,7 +36,7 @@ import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
export default function UploadOptionsButton({ numFiles }: { numFiles: number }) {
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
const config = useConfig();
const [opened, setOpen] = useState(false);
@@ -65,14 +65,14 @@ export default function UploadOptionsButton({ numFiles }: { numFiles: number })
const combobox = useCombobox();
const [folderSearch, setFolderSearch] = useState('');
useEffect(
() =>
useUploadOptionsStore.subscribe(
(state) => state.ephemeral,
(current) => (current.folderId === null ? setFolderSearch('') : null),
),
[],
);
useEffect(() => {
if (folder) return;
useUploadOptionsStore.subscribe(
(state) => state.ephemeral,
(current) => (current.folderId === null ? setFolderSearch('') : null),
);
}, []);
return (
<>
@@ -236,6 +236,7 @@ export default function UploadOptionsButton({ numFiles }: { numFiles: number })
setEphemeral('folderId', value === 'no folder' || value === '' ? null : value);
combobox.closeDropdown();
}}
disabled={!!folder}
>
<Combobox.Target>
<InputBase

View File

@@ -103,6 +103,7 @@ export function uploadFiles(
clearEphemeral,
options,
ephemeral,
folder,
}: {
setProgress: (o: { percent: number; remaining: number; speed: number }) => void;
setLoading: (loading: boolean) => void;
@@ -111,6 +112,7 @@ export function uploadFiles(
clearEphemeral: () => void;
options: UploadOptionsStore['options'];
ephemeral: UploadOptionsStore['ephemeral'];
folder?: string;
},
) {
setLoading(true);
@@ -193,7 +195,12 @@ export function uploadFiles(
ephemeral.password && req.setRequestHeader('x-zipline-password', ephemeral.password);
ephemeral.filename && req.setRequestHeader('x-zipline-filename', encodeURIComponent(ephemeral.filename));
ephemeral.folderId && req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
if (folder) {
req.setRequestHeader('x-zipline-folder', folder);
} else if (ephemeral.folderId) {
req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
}
req.send(body);
}

View File

@@ -81,6 +81,7 @@ export async function uploadPartialFiles(
options,
ephemeral,
config,
folder,
}: {
setProgress: (o: { percent: number; remaining: number; speed: number }) => void;
setLoading: (loading: boolean) => void;
@@ -90,6 +91,7 @@ export async function uploadPartialFiles(
options: UploadOptionsStore['options'];
ephemeral: UploadOptionsStore['ephemeral'];
config: ReturnType<typeof useConfig>;
folder?: string;
},
) {
setLoading(true);
@@ -249,7 +251,12 @@ export async function uploadPartialFiles(
ephemeral.password && req.setRequestHeader('x-zipline-password', ephemeral.password);
ephemeral.filename &&
req.setRequestHeader('x-zipline-filename', encodeURIComponent(ephemeral.filename));
ephemeral.folderId && req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
if (folder) {
req.setRequestHeader('x-zipline-folder', folder);
} else if (ephemeral.folderId) {
req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
}
req.setRequestHeader('x-zipline-p-identifier', identifier);
req.setRequestHeader('x-zipline-p-filename', encodeURIComponent(file.name));

View File

@@ -57,15 +57,17 @@ export async function handlePartialUpload({
if (mime) mimetype = mime;
}
let folder = null;
if (options.folder) {
const exists = await prisma.folder.findFirst({
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
userId: req.user.id,
},
});
if (!exists) throw 'Folder does not exist';
if (!folder) throw 'Folder does not exist';
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
}
const tempFile = join(
@@ -82,7 +84,7 @@ export async function handlePartialUpload({
type: mimetype,
User: {
connect: {
id: req.user.id,
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
},
...(options.password && { password: await hashPassword(options.password) }),
@@ -98,7 +100,7 @@ export async function handlePartialUpload({
new Worker('./build/offload/partial.js', {
workerData: {
user: {
id: req.user.id,
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
file: {
id: fileUpload.id,

View File

@@ -86,15 +86,17 @@ export async function handleFile({
}
}
let folder = null;
if (options.folder) {
const exists = await prisma.folder.findFirst({
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
userId: req.user.id,
},
});
if (!exists) throw 'Folder does not exist';
if (!folder) throw 'Folder does not exist';
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
}
let compressed = false;
@@ -123,7 +125,7 @@ export async function handleFile({
type: compressed ? 'image/jpeg' : mimetype,
User: {
connect: {
id: req.user.id,
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
},
...(options.maxViews && { maxViews: options.maxViews }),
@@ -150,10 +152,19 @@ export async function handleFile({
...(compressed && { compressed: true }),
});
logger.info(`${req.user.username} uploaded ${fileUpload.name}`, { size: bytes(fileUpload.size) });
logger.info(`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`, {
size: bytes(fileUpload.size),
ip: req.ip,
});
await onUpload({
user: req.user,
user: req.user ?? {
id: 'anonymous',
username: 'anonymous',
createdAt: new Date(),
updatedAt: new Date(),
role: 'USER',
},
file: fileUpload,
link: {
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,

View File

@@ -5,11 +5,13 @@ export type Folder = PrismaFolder & {
files?: File[];
};
export function cleanFolder(folder: Folder, stringifyDates = false) {
export function cleanFolder(folder: Partial<Folder>, stringifyDates = false) {
if (folder.files) cleanFiles(folder.files, stringifyDates);
(folder as any).createdAt = stringifyDates ? folder.createdAt.toISOString() : folder.createdAt;
(folder as any).updatedAt = stringifyDates ? folder.updatedAt.toISOString() : folder.updatedAt;
if (folder.createdAt)
(folder as any).createdAt = stringifyDates ? folder.createdAt.toISOString() : folder.createdAt;
if (folder.updatedAt)
(folder as any).updatedAt = stringifyDates ? folder.updatedAt.toISOString() : folder.updatedAt;
return folder;
}

View File

@@ -0,0 +1,54 @@
import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { prisma } from '@/lib/db';
import { Folder, cleanFolder } from '@/lib/db/models/folder';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { Container } from '@mantine/core';
import { InferGetServerSidePropsType } from 'next';
import Head from 'next/head';
export default function ViewFolderId({
folder,
config,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (!folder) return null;
return (
<>
<Head>
<title>{`${config.website.title ?? 'Zipline'} Upload to ${folder.name}`}</title>
</Head>
<Container mt='lg'>
<ConfigProvider config={config}>
<UploadFile title={`Upload files to ${folder.name}`} folder={folder.id} />
</ConfigProvider>
</Container>
</>
);
}
export const getServerSideProps = withSafeConfig<{
folder?: Partial<Folder>;
}>(async (ctx) => {
const { id } = ctx.query;
if (!id) return { notFound: true };
const folder = await prisma.folder.findUnique({
where: {
id: id as string,
},
select: {
id: true,
name: true,
allowUploads: true,
},
});
if (!folder) return { notFound: true };
if (!folder.allowUploads) return { notFound: true };
return {
folder: cleanFolder(folder, true),
};
});

View File

@@ -5,6 +5,7 @@ import { User, userSelect } from '@/lib/db/models/user';
import { FastifyReply } from 'fastify';
import { FastifyRequest } from 'fastify/types/request';
import { getSession } from '../session';
import { parseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
declare module 'fastify' {
export interface FastifyRequest {
@@ -39,6 +40,16 @@ export function parseUserToken(
}
export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
const cookies = parseCookie(req.headers.cookie ?? '');
// conditions met to allow anonymous folder uploads but later handled in the upload route
const anonFolderUpload =
req.headers['x-zipline-folder'] &&
req.url.toLowerCase().trim() === '/api/upload' &&
!req.headers.authorization &&
!cookies.has('zipline_session');
if (anonFolderUpload) return;
const authorization = req.headers.authorization;
if (authorization) {

View File

@@ -40,6 +40,16 @@ export default fastifyPlugin(
const options = parseHeaders(req.headers, config.files);
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
if (options.folder) {
const folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) return res.badRequest('folder not found');
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
}
const filesIterable = req.files();
const files: MultipartFileBuffer[] = [];
@@ -48,7 +58,7 @@ export default fastifyPlugin(
files.push(<MultipartFileBuffer>file);
}
if (req.user.quota) {
if (req.user?.quota) {
const totalFileSize = options.partial
? options.partial.contentLength
: files.reduce((acc, x) => acc + x.file.bytesRead, 0);

View File

@@ -15,6 +15,7 @@ type Body = {
id?: string;
isPublic?: boolean;
name?: string;
allowUploads?: boolean;
delete?: 'file' | 'folder';
};
@@ -100,7 +101,7 @@ export default fastifyPlugin(
return res.send(cleanFolder(nFolder));
} else if (req.method === 'PATCH') {
const { isPublic, name } = req.body;
const { isPublic, name, allowUploads } = req.body;
const nFolder = await prisma.folder.update({
where: {
@@ -109,6 +110,7 @@ export default fastifyPlugin(
data: {
...(isPublic !== undefined && { public: isPublic }),
...(name && { name }),
...(allowUploads !== undefined && { allowUploads }),
},
include: {
files: {
@@ -123,6 +125,8 @@ export default fastifyPlugin(
logger.info('folder updated', {
folder: folder.id,
isPublic,
name,
allowUploads,
});
return res.send(cleanFolder(nFolder));