feat: storage/file quota + urls quota

This commit is contained in:
diced
2024-04-16 20:34:04 -07:00
parent f1e2d50fd5
commit 7e137c0991
12 changed files with 272 additions and 34 deletions

View File

@@ -30,16 +30,37 @@ model User {
totpSecret String? totpSecret String?
passkeys UserPasskey[] passkeys UserPasskey[]
quota UserQuota?
files File[] files File[]
urls Url[] urls Url[]
folders Folder[] folders Folder[]
limits UserLimit[]
invites Invite[] invites Invite[]
tags Tag[] tags Tag[]
oauthProviders OAuthProvider[] oauthProviders OAuthProvider[]
IncompleteFile IncompleteFile[] IncompleteFile IncompleteFile[]
} }
model UserQuota {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
filesQuota UserFilesQuota
maxBytes String?
maxFiles Int?
maxUrls Int?
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @unique
}
enum UserFilesQuota {
BY_BYTES
BY_FILES
}
model UserPasskey { model UserPasskey {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -85,35 +106,6 @@ enum OAuthProviderType {
AUTHENTIK AUTHENTIK
} }
model UserLimit {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
type LimitType @unique
value Int
timeframe LimitTimeframe
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String
}
enum LimitType {
UPLOAD_COUNT
UPLOAD_SIZE
SHORTEN_COUNT
}
enum LimitTimeframe {
SECONDLY
MINUTELY
HOURLY
DAILY
WEEKLY
MONTHLY
YEARLY
}
model File { model File {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -20,6 +20,27 @@ export default function DashboardHome() {
<Text size='sm' c='dimmed'> <Text size='sm' c='dimmed'>
You have <b>{statsLoading ? '...' : stats?.filesUploaded}</b> files uploaded. You have <b>{statsLoading ? '...' : stats?.filesUploaded}</b> files uploaded.
</Text> </Text>
{user?.quota && (user.quota.maxBytes || user.quota.maxFiles) ? (
<Text size='sm' c='dimmed'>
{user.quota.filesQuota === 'BY_BYTES' ? (
<>
You have used <b>{statsLoading ? '...' : bytes(stats!.storageUsed)}</b> out of{' '}
<b>{user.quota.maxBytes}</b> of storage
</>
) : (
<>
You have uploaded <b>{statsLoading ? '...' : stats?.filesUploaded}</b> files out of{' '}
<b>{user.quota.maxFiles}</b> files allowed.
</>
)}
</Text>
) : null}
{user?.quota && user.quota.maxUrls ? (
<Text size='sm' c='dimmed'>
You have created <b>{statsLoading ? '...' : stats?.urlsCreated}</b> links out of{' '}
<b>{user.quota.maxUrls}</b> links allowed.
</Text>
) : null}
<Title order={2} mt='md' mb='xs'> <Title order={2} mt='md' mb='xs'>
Recent files Recent files

View File

@@ -227,6 +227,7 @@ export async function uploadPartialFiles(
req.setRequestHeader('x-zipline-p-filename', file.name); req.setRequestHeader('x-zipline-p-filename', file.name);
req.setRequestHeader('x-zipline-p-lastchunk', j === chunks.length - 1 ? 'true' : 'false'); req.setRequestHeader('x-zipline-p-lastchunk', j === chunks.length - 1 ? 'true' : 'false');
req.setRequestHeader('x-zipline-p-content-type', file.type); req.setRequestHeader('x-zipline-p-content-type', file.type);
req.setRequestHeader('x-zipline-p-content-length', file.size.toString());
req.setRequestHeader('content-range', `bytes ${chunks[j].start}-${chunks[j].end}/${file.size}`); req.setRequestHeader('content-range', `bytes ${chunks[j].start}-${chunks[j].end}/${file.size}`);
req.send(body); req.send(body);

View File

@@ -1,14 +1,17 @@
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { readToDataURL } from '@/lib/base64';
import { bytes } from '@/lib/bytes';
import { User } from '@/lib/db/models/user'; import { User } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi'; import { fetchApi } from '@/lib/fetchApi';
import { readToDataURL } from '@/lib/base64';
import { canInteract } from '@/lib/role'; import { canInteract } from '@/lib/role';
import { useUserStore } from '@/lib/store/user'; import { useUserStore } from '@/lib/store/user';
import { import {
ActionIcon, ActionIcon,
Button, Button,
Divider,
FileInput, FileInput,
Modal, Modal,
NumberInput,
PasswordInput, PasswordInput,
Select, Select,
Stack, Stack,
@@ -20,6 +23,7 @@ import {
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconPhotoMinus, IconUserCancel, IconUserEdit } from '@tabler/icons-react'; import { IconPhotoMinus, IconUserCancel, IconUserEdit } from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutate } from 'swr'; import { mutate } from 'swr';
export default function EditUserModal({ export default function EditUserModal({
@@ -38,18 +42,40 @@ export default function EditUserModal({
password: string; password: string;
role: 'USER' | 'ADMIN' | 'SUPERADMIN'; role: 'USER' | 'ADMIN' | 'SUPERADMIN';
avatar: File | null; avatar: File | null;
fileType: 'BY_BYTES' | 'BY_FILES' | 'NONE';
maxFiles: number;
maxBytes: string;
maxUrls: number;
}>({ }>({
initialValues: { initialValues: {
username: user?.username || '', username: user?.username || '',
password: '', password: '',
role: user?.role || 'USER', role: user?.role || 'USER',
avatar: null, avatar: null,
fileType: user?.quota?.filesQuota || 'NONE',
maxFiles: user?.quota?.maxFiles || 0,
maxBytes: user?.quota?.maxBytes || '',
maxUrls: user?.quota?.maxUrls || 0,
},
validate: {
maxBytes(value, values) {
if (values.fileType !== 'BY_BYTES') return;
if (typeof value !== 'string') return 'Invalid value';
const byte = bytes(value);
if (!bytes || byte < 0) return 'Invalid byte format';
},
maxFiles(value, values) {
if (values.fileType !== 'BY_FILES') return;
if (typeof value !== 'number' || value < 0) return 'Invalid value';
},
}, },
}); });
const onSubmit = async (values: typeof form.values) => { const onSubmit = async (values: typeof form.values) => {
if (!user) return; if (!user) return;
console.log(values);
let avatar64: string | null = null; let avatar64: string | null = null;
if (values.avatar) { if (values.avatar) {
if (!values.avatar.type.startsWith('image/')) return form.setFieldError('avatar', 'Invalid file type'); if (!values.avatar.type.startsWith('image/')) return form.setFieldError('avatar', 'Invalid file type');
@@ -64,11 +90,35 @@ export default function EditUserModal({
} }
} }
const finalQuota: {
filesType?: 'BY_BYTES' | 'BY_FILES' | 'NONE';
maxFiles?: number | null;
maxBytes?: string | null;
maxUrls?: number | null;
} = {};
if (values.fileType === 'NONE') {
finalQuota.filesType = 'NONE';
finalQuota.maxFiles = null;
finalQuota.maxBytes = null;
finalQuota.maxUrls = null;
} else if (values.fileType === 'BY_BYTES') {
finalQuota.filesType = 'BY_BYTES';
finalQuota.maxBytes = values.maxBytes;
} else {
finalQuota.filesType = 'BY_FILES';
finalQuota.maxFiles = values.maxFiles;
}
if (values.maxUrls) finalQuota.maxUrls = values.maxUrls > 0 ? values.maxUrls : null;
const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'PATCH', { const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'PATCH', {
...(values.username !== user.username && { username: values.username }), ...(values.username !== user.username && { username: values.username }),
...(values.password && { password: values.password }), ...(values.password && { password: values.password }),
...(values.role !== user.role && { role: values.role }), ...(values.role !== user.role && { role: values.role }),
...(avatar64 && { avatar: avatar64 }), ...(avatar64 && { avatar: avatar64 }),
quota: finalQuota,
}); });
if (error) { if (error) {
@@ -92,6 +142,19 @@ export default function EditUserModal({
} }
}; };
useEffect(() => {
form.setValues({
username: user?.username || '',
password: '',
role: user?.role || 'USER',
avatar: null,
fileType: user?.quota?.filesQuota || 'NONE',
maxFiles: user?.quota?.maxFiles || 0,
maxBytes: user?.quota?.maxBytes || '',
maxUrls: user?.quota?.maxUrls || 0,
});
}, [user]);
return ( return (
<Modal centered title={<Title>Edit {user?.username ?? ''}</Title>} onClose={onClose} opened={opened}> <Modal centered title={<Title>Edit {user?.username ?? ''}</Title>} onClose={onClose} opened={opened}>
<Text size='sm' c='dimmed'> <Text size='sm' c='dimmed'>
@@ -142,6 +205,44 @@ export default function EditUserModal({
{...form.getInputProps('role')} {...form.getInputProps('role')}
/> />
<Divider />
<Title order={4}>Quota</Title>
<Select
label='File Quota Type'
description='Whether to set a quota on files by total bytes or the total number of files.'
data={[
{ value: 'BY_BYTES', label: 'By Bytes' },
{ value: 'BY_FILES', label: 'By File Count' },
{ value: 'NONE', label: 'No File Quota' },
]}
{...form.getInputProps('fileType')}
/>
{form.values.fileType === 'BY_FILES' ? (
<NumberInput
label='Max Files'
description='The maximum number of files the user can upload.'
placeholder='Enter a number...'
mx='lg'
min={0}
{...form.getInputProps('maxFiles')}
/>
) : form.values.fileType === 'BY_BYTES' ? (
<TextInput
label='Max Bytes'
description='The maximum number of bytes the user can upload.'
placeholder='Enter a human readable byte-format...'
mx='lg'
{...form.getInputProps('maxBytes')}
/>
) : null}
<NumberInput
label='Max URLs'
placeholder='Enter a number...'
{...form.getInputProps('maxUrls')}
/>
<Button <Button
type='submit' type='submit'
variant='outline' variant='outline'

View File

@@ -4,5 +4,5 @@ export function bytes(value: string): number;
export function bytes(value: number, options?: BytesOptions): string; export function bytes(value: number, options?: BytesOptions): string;
export function bytes(value: string | number, options?: BytesOptions): string | number { export function bytes(value: string | number, options?: BytesOptions): string | number {
if (typeof value === 'string') return bytesFn(value); if (typeof value === 'string') return bytesFn(value);
return bytesFn(value, { ...options, unitSeparator: ' ' }); return bytesFn(Number(value), { ...options, unitSeparator: ' ' });
} }

View File

@@ -1,4 +1,4 @@
import { OAuthProvider, UserPasskey } from '@prisma/client'; import { OAuthProvider, UserPasskey, UserQuota } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export type User = { export type User = {
@@ -14,6 +14,8 @@ export type User = {
totpSecret?: string | null; totpSecret?: string | null;
passkeys?: UserPasskey[]; passkeys?: UserPasskey[];
quota?: UserQuota | null;
avatar?: string | null; avatar?: string | null;
password?: string | null; password?: string | null;
token?: string | null; token?: string | null;
@@ -29,6 +31,7 @@ export const userSelect = {
oauthProviders: true, oauthProviders: true,
totpSecret: true, totpSecret: true,
passkeys: true, passkeys: true,
quota: true,
}; };
export type UserViewSettings = z.infer<typeof userViewSchema>; export type UserViewSettings = z.infer<typeof userViewSchema>;

View File

@@ -46,6 +46,14 @@ export function functions() {
}); });
}; };
res.tooLarge = (message: string = 'Payload Too Large', data: ErrorBody = {}) => {
return res.status(413).json({
code: 413,
message,
...data,
});
};
res.ratelimited = (retryAfter: number, message: string = 'Ratelimited', data: ErrorBody = {}) => { res.ratelimited = (retryAfter: number, message: string = 'Ratelimited', data: ErrorBody = {}) => {
res.setHeader('Retry-After', retryAfter); res.setHeader('Retry-After', retryAfter);
return res.status(429).json({ return res.status(429).json({

View File

@@ -35,6 +35,7 @@ export interface NextApiRes<Data = any> extends NextApiResponse {
unauthorized: (message?: string, data?: ErrorBody) => void; unauthorized: (message?: string, data?: ErrorBody) => void;
forbidden: (message?: string, data?: ErrorBody) => void; forbidden: (message?: string, data?: ErrorBody) => void;
notFound: (message?: string, data?: ErrorBody) => void; notFound: (message?: string, data?: ErrorBody) => void;
tooLarge: (message?: string, data?: ErrorBody) => void;
ratelimited: (retryAfter: number, message?: string, data?: ErrorBody) => void; ratelimited: (retryAfter: number, message?: string, data?: ErrorBody) => void;
serverError: (message?: string, data?: ErrorBody) => void; serverError: (message?: string, data?: ErrorBody) => void;
methodNotAllowed: (message?: string, data?: ErrorBody) => void; methodNotAllowed: (message?: string, data?: ErrorBody) => void;

View File

@@ -59,6 +59,7 @@ export type UploadHeaders = {
'x-zipline-p-content-type'?: string; 'x-zipline-p-content-type'?: string;
'x-zipline-p-identifier'?: string; 'x-zipline-p-identifier'?: string;
'x-zipline-p-lastchunk'?: StringBoolean; 'x-zipline-p-lastchunk'?: StringBoolean;
'x-zipline-p-content-length'?: string;
}; };
export type UploadOptions = { export type UploadOptions = {
@@ -88,6 +89,7 @@ export type UploadOptions = {
identifier: string; identifier: string;
lastchunk: boolean; lastchunk: boolean;
range: [number, number, number]; // start, end, total range: [number, number, number]; // start, end, total
contentLength: number;
}; };
}; };
@@ -222,6 +224,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
identifier: headers['x-zipline-p-identifier']!, identifier: headers['x-zipline-p-identifier']!,
lastchunk: headers['x-zipline-p-lastchunk'] === 'true', lastchunk: headers['x-zipline-p-lastchunk'] === 'true',
range: [start, end, total], range: [start, end, total],
contentLength: Number(headers['x-zipline-p-content-length']!),
}; };
} }

View File

@@ -1,6 +1,8 @@
import { handlePartialUpload } from '@/lib/api/partialUpload'; import { handlePartialUpload } from '@/lib/api/partialUpload';
import { handleFile } from '@/lib/api/upload'; import { handleFile } from '@/lib/api/upload';
import { bytes } from '@/lib/bytes';
import { config as zconfig } from '@/lib/config'; import { config as zconfig } from '@/lib/config';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger'; import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine'; import { combine } from '@/lib/middleware/combine';
import { file } from '@/lib/middleware/file'; import { file } from '@/lib/middleware/file';
@@ -32,6 +34,36 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
if (options.header) return res.badRequest('', options); if (options.header) return res.badRequest('', options);
if (req.user.quota) {
const totalFileSize = options.partial
? options.partial.contentLength
: req.files.reduce((acc, x) => acc + x.size, 0);
const userAggregateStats = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_sum: {
size: true,
},
_count: {
_all: true,
},
});
const aggSize = userAggregateStats!._sum?.size === null ? 0 : userAggregateStats!._sum?.size;
if (req.user.quota.filesQuota === 'BY_BYTES' && aggSize + totalFileSize > bytes(req.user.quota.maxBytes!))
return res.tooLarge(
`uploading will exceed your storage quota of ${bytes(req.user.quota.maxBytes!)} bytes`,
);
if (
req.user.quota.filesQuota === 'BY_FILES' &&
userAggregateStats!._count?._all + req.files.length > req.user.quota.maxFiles!
)
return res.tooLarge(`uploading will exceed your file count quota of ${req.user.quota.maxFiles} files`);
}
const response: ApiUploadResponse = { const response: ApiUploadResponse = {
files: [], files: [],
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }), ...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),

View File

@@ -44,6 +44,14 @@ export async function handler(req: NextApiReq<Body, Query, Headers>, res: NextAp
const { vanity, destination } = req.body; const { vanity, destination } = req.body;
const noJson = !!req.headers['x-zipline-no-json']; const noJson = !!req.headers['x-zipline-no-json'];
const countUrls = await prisma.url.count({
where: {
userId: req.user.id,
},
});
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
return res.forbidden(`shortenning this url would exceed your quota of ${req.user.quota.maxUrls} urls`);
let maxViews: number | undefined; let maxViews: number | undefined;
const returnDomain = req.headers['x-zipline-domain']; const returnDomain = req.headers['x-zipline-domain'];

View File

@@ -1,3 +1,4 @@
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto'; import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource'; import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
@@ -8,6 +9,7 @@ import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth'; import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response'; import { NextApiReq, NextApiRes } from '@/lib/response';
import { canInteract } from '@/lib/role'; import { canInteract } from '@/lib/role';
import { UserFilesQuota } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
export type ApiUsersIdResponse = User; export type ApiUsersIdResponse = User;
@@ -17,6 +19,13 @@ type Body = {
password?: string; password?: string;
avatar?: string; avatar?: string;
role?: 'USER' | 'ADMIN' | 'SUPERADMIN'; role?: 'USER' | 'ADMIN' | 'SUPERADMIN';
quota?: {
filesType?: UserFilesQuota & 'NONE';
maxFiles?: number;
maxBytes?: string;
maxUrls?: number;
};
delete?: boolean; delete?: boolean;
}; };
@@ -26,6 +35,7 @@ type Query = {
}; };
const logger = log('api').c('users').c('[id]'); const logger = log('api').c('users').c('[id]');
const zNumber = z.number();
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersIdResponse>) { export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersIdResponse>) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -37,13 +47,55 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
if (!user) return res.notFound('User not found'); if (!user) return res.notFound('User not found');
if (req.method === 'PATCH') { if (req.method === 'PATCH') {
const { username, password, avatar, role } = req.body; const { username, password, avatar, role, quota } = req.body;
if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success) if (role && !z.enum(['USER', 'ADMIN']).safeParse(role).success)
return res.badRequest('Invalid role (USER, ADMIN)'); return res.badRequest('Invalid role (USER, ADMIN)');
if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role'); if (role && !canInteract(req.user.role, role)) return res.forbidden('You cannot create this role');
let finalQuota:
| {
filesQuota?: UserFilesQuota;
maxFiles?: number | null;
maxBytes?: string | null;
maxUrls?: number | null;
}
| undefined = undefined;
if (quota) {
if (quota.filesType && !z.enum(['BY_BYTES', 'BY_FILES', 'NONE']).safeParse(quota.filesType).success)
return res.badRequest('Invalid filesType (BY_BYTES, BY_FILES, NONE)');
if (quota.maxFiles && !zNumber.safeParse(quota.maxFiles).success)
return res.badRequest('Invalid maxFiles');
if (quota.maxUrls && !zNumber.safeParse(quota.maxUrls).success)
return res.badRequest('Invalid maxUrls');
if (quota.filesType === 'BY_BYTES' && quota.maxBytes === undefined)
return res.badRequest('maxBytes is required');
if (quota.filesType === 'BY_FILES' && quota.maxFiles === undefined)
return res.badRequest('maxFiles is required');
finalQuota = {
...(quota.filesType === 'BY_BYTES' && {
filesQuota: 'BY_BYTES',
maxBytes: bytes(quota.maxBytes || '0') > 0 ? quota.maxBytes : null,
maxFiles: null,
}),
...(quota.filesType === 'BY_FILES' && {
filesQuota: 'BY_FILES',
maxFiles: quota.maxFiles,
maxBytes: null,
}),
...(quota.filesType === 'NONE' && {
filesQuota: 'BY_BYTES',
maxFiles: null,
maxBytes: null,
}),
maxUrls: (quota.maxUrls || 0) > 0 ? quota.maxUrls : null,
};
}
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {
id: user.id, id: user.id,
@@ -53,6 +105,22 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
...(password && { password: await hashPassword(password) }), ...(password && { password: await hashPassword(password) }),
...(role !== undefined && { role: 'USER' }), ...(role !== undefined && { role: 'USER' }),
...(avatar && { avatar }), ...(avatar && { avatar }),
...(finalQuota && {
quota: {
upsert: {
where: {
userId: user.id,
},
create: {
filesQuota: finalQuota.filesQuota || 'BY_BYTES',
maxFiles: finalQuota.maxFiles ?? null,
maxBytes: finalQuota.maxBytes ?? null,
maxUrls: finalQuota.maxUrls ?? null,
},
update: finalQuota,
},
},
}),
}, },
select: { select: {
...userSelect, ...userSelect,