Compare commits

..

4 Commits

Author SHA1 Message Date
diced adb984b2db ima kms 2023-04-30 15:21:25 -07:00
dicedtomato 3be9f1521e Merge branch 'trunk' into feature/file-tags 2023-04-04 20:08:03 -07:00
dicedtomato 5d971a9fef Merge branch 'trunk' into feature/file-tags 2023-04-04 19:13:05 -07:00
diced 2c86abbf4e feat: file tags (experimental) 2023-04-04 19:12:06 -07:00
74 changed files with 1282 additions and 1350 deletions
+17 -21
View File
@@ -1,50 +1,46 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
# if using s3/supabase make sure to comment out the other datasources
CORE_RETURN_HTTPS=true
CORE_HTTPS=true
CORE_SECRET="changethis"
CORE_HOST=0.0.0.0
CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_LOGGER=false
CORE_STATS_INTERVAL=1800
CORE_INVITES_INTERVAL=1800
CORE_THUMBNAILS_INTERVAL=600
# default
DATASOURCE_TYPE=local
DATASOURCE_LOCAL_DIRECTORY=./uploads
# or you can choose to use s3
# DATASOURCE_TYPE=s3
# DATASOURCE_S3_ACCESS_KEY_ID=key
# DATASOURCE_S3_SECRET_ACCESS_KEY=secret
# DATASOURCE_S3_BUCKET=bucket
# DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
# DATASOURCE_S3_REGION=us-west-2
# DATASOURCE_S3_FORCE_S3_PATH=false
# DATASOURCE_S3_USE_SSL=false
DATASOURCE_TYPE=s3
DATASOURCE_S3_ACCESS_KEY_ID=key
DATASOURCE_S3_SECRET_ACCESS_KEY=secret
DATASOURCE_S3_BUCKET=bucket
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
DATASOURCE_S3_REGION=us-west-2
DATASOURCE_S3_FORCE_S3_PATH=false
DATASOURCE_S3_USE_SSL=false
# or supabase
# DATASOURCE_TYPE=supabase
# DATASOURCE_SUPABASE_KEY=xxx
DATASOURCE_TYPE=supabase
DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
# DATASOURCE_SUPABASE_BUCKET=zipline
DATASOURCE_SUPABASE_URL=https://something.supabase.co
DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6
UPLOADER_ADMIN_LIMIT=104900000
UPLOADER_USER_LIMIT=104900000
UPLOADER_DISABLED_EXTENSIONS=someext,anotherext
UPLOADER_DISABLED_EXTENSIONS=someext
URLS_ROUTE=/go
URLS_LENGTH=6
RATELIMIT_USER=5
RATELIMIT_ADMIN=3
# for more variables checkout the docs
RATELIMIT_USER = 5
RATELIMIT_ADMIN = 3
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: diced
+2 -2
View File
@@ -15,10 +15,10 @@ body:
id: version
attributes:
label: Version
description: What version (or docker image) of Zipline are you using?
description: What version of Zipline are you using?
options:
- latest (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- upstream (ghcr.io/diced/zipline:trunk)
- latest (ghcr.io/diced/zipline:latest)
- other (provide version in additional info)
validations:
required: true
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Feature Request
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20brief%20description%20here&labels=feature
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%20description%20here&labels=feature
about: Ask for a new feature
- name: Zipline Discord
url: https://discord.gg/EAhCRfGxCF
+1 -1
View File
@@ -14,7 +14,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
Create an discussion on GitHub, please include the following:
- Brief explanation of the feature in the title (very brief please)
- Breif explanation of the feature in the title (very breif please)
- How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase)
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 dicedtomato
Copyright (c) 2022 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+2 -2
View File
@@ -23,8 +23,8 @@ services:
env_file:
- .env.local
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
+1 -1
View File
@@ -29,7 +29,7 @@ services:
- CORE_LOGGER=true
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.1",
"version": "3.7.0",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@@ -24,8 +24,7 @@
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size",
"scripts:clear-temp": "node --enable-source-maps dist/scripts/clear-temp"
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
},
"dependencies": {
"@emotion/react": "^11.10.6",
@@ -54,7 +53,6 @@
"fastify": "^4.15.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"ffmpeg-static": "^5.1.0",
"find-my-way": "^7.6.0",
"katex": "^0.16.4",
"mantine-datatable": "^2.2.6",
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_FileToTag" (
"A" INTEGER NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_FileToTag_AB_unique" ON "_FileToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag"("B");
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,16 +0,0 @@
-- CreateTable
CREATE TABLE "Thumbnail" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"fileId" INTEGER NOT NULL,
CONSTRAINT "Thumbnail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail"("fileId");
-- AddForeignKey
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+27 -28
View File
@@ -8,24 +8,24 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
username String
password String?
avatar String?
token String
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embed Json @default("{}")
ratelimit DateTime?
totpSecret String?
domains String[]
oauth OAuth[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
id Int @id @default(autoincrement())
uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
username String
password String?
avatar String?
token String
administrator Boolean @default(false)
superAdmin Boolean @default(false)
systemTheme String @default("system")
embed Json @default("{}")
ratelimit DateTime?
totpSecret String?
domains String[]
oauth OAuth[]
files File[]
urls Url[]
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
}
@@ -60,19 +60,18 @@ model File {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
thumbnail Thumbnail?
tags Tag[]
}
model Thumbnail {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
model Tag {
id String @id @default(cuid())
name String
color String
fileId Int @unique
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
files File[]
}
model InvisibleFile {
@@ -125,7 +124,7 @@ model OAuth {
id Int @id @default(autoincrement())
provider OauthProviders
user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
userId String @db.Uuid
userId String
username String
oauthId String?
token String
+159 -39
View File
@@ -3,11 +3,14 @@ import {
Group,
LoadingOverlay,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
Title,
Tooltip,
Text,
Accordion,
} from '@mantine/core';
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
@@ -24,21 +27,27 @@ import {
IconFolderCancel,
IconFolderMinus,
IconFolderPlus,
IconFolders,
IconHash,
IconInfoCircle,
IconPhoto,
IconPhotoCancel,
IconPhotoMinus,
IconPhotoStar,
IconPlus,
IconTags,
} from '@tabler/icons-react';
import useFetch, { ApiError } from 'hooks/useFetch';
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { bytesToHuman } from 'lib/utils/bytes';
import { relativeTime } from 'lib/utils/client';
import { colorHash, relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import { FileMeta } from '.';
import Type from '../Type';
import Tag from 'components/File/tag/Tag';
import Item from 'components/File/tag/Item';
import { useDeleteFileTags, useFileTags, useTags, useUpdateFileTags } from 'lib/queries/tags';
export default function FileModal({
open,
@@ -49,7 +58,6 @@ export default function FileModal({
reducedActions = false,
exifEnabled,
compress,
otherUser = false,
}: {
open: boolean;
setOpen: (open: boolean) => void;
@@ -59,14 +67,18 @@ export default function FileModal({
reducedActions?: boolean;
exifEnabled?: boolean;
compress: boolean;
otherUser: boolean;
}) {
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const folders = useFolders();
const tags = useFileTags(file.id);
const updateTags = useUpdateFileTags(file.id);
const removeTags = useDeleteFileTags(file.id);
const clipboard = useClipboard();
const allTags = useTags();
const [overrideRender, setOverrideRender] = useState(false);
const clipboard = useClipboard();
const handleDelete = async () => {
deleteFile.mutate(file.id, {
@@ -97,12 +109,18 @@ export default function FileModal({
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
setOpen(false);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
};
const handleFavorite = async () => {
@@ -205,12 +223,50 @@ export default function FileModal({
return { value: t, label: t };
};
const handleTagsSave = () => {
console.log('should save');
};
const handleAddTags = (t: string[]) => {
// filter out existing tags from t
t = t.filter((tag) => !tags.data.find((t) => t.id === tag));
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
if (!fullTag) return;
updateTags.mutate([...tags.data, fullTag], {
onSuccess: () => {
showNotification({
title: 'Added tag',
message: fullTag.name,
color: 'green',
icon: <IconTags size='1rem' />,
});
},
});
};
const handleRemoveTags = (t: string[]) => {
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
removeTags.mutate(t, {
onSuccess: () =>
showNotification({
title: 'Removed tag',
message: fullTag.name,
color: 'green',
icon: <IconTags size='1rem' />,
}),
});
};
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{file.name}</Title>}
size='auto'
size='lg'
fullScreen={useMediaQuery('(max-width: 600px)')}
>
<LoadingOverlay visible={loading} />
@@ -220,8 +276,6 @@ export default function FileModal({
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
alt={file.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
@@ -265,6 +319,98 @@ export default function FileModal({
</SimpleGrid>
</Stack>
{!reducedActions ? (
<Accordion
variant='contained'
mb='sm'
styles={(t) => ({
content: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
control: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
})}
>
<Accordion.Item value='tags'>
<Accordion.Control icon={<IconTags size='1rem' />}>Tags</Accordion.Control>
<Accordion.Panel>
<MultiSelect
value={tags.data?.map((t) => t.id) ?? []}
data={allTags.data?.map((t) => ({ value: t.id, label: t.name, color: t.color })) ?? []}
placeholder={allTags.data?.length ? 'Add tags' : 'Add tags (optional)'}
icon={<IconTags size='1rem' />}
valueComponent={Tag}
itemComponent={Item}
searchable
creatable
getCreateLabel={(t) => (
<Group>
<IconPlus size='1rem' />
<Text ml='sm' display='flex'>
Create tag{' '}
<Text ml={4} color={colorHash(t)}>
&quot;{t}&quot;
</Text>
</Text>
</Group>
)}
// onChange={(t) => (t.length === 1 ? handleRemoveTags(t) : handleAddTags(t))}
onChange={(t) => console.log(t)}
onCreate={(t) => {
const item = { value: t, label: t, color: colorHash(t) };
// setLabelTags([...labelTags, item]);
return item;
}}
onBlur={handleTagsSave}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='folders'>
<Accordion.Control icon={<IconFolders size='1rem' />}>Folders</Accordion.Control>
<Accordion.Panel>
{inFolder && !folders.isLoading ? (
<Group>
<Tooltip
label={`Remove from folder "${
folders.data.find((f) => f.id === file.folderId)?.name ?? ''
}"`}
>
<ActionIcon
color='red'
variant='filled'
onClick={removeFromFolder}
loading={folders.isLoading}
>
<IconFolderMinus size='1rem' />
</ActionIcon>
</Tooltip>
<Text display='flex' align='center'>
Currently in folder &quot;{folders.data.find((f) => f.id === file.folderId)?.name ?? ''}
&quot;
</Text>
</Group>
) : (
<Tooltip label='Add to folder'>
<Select
icon={<IconFolderPlus size='1rem' />}
onChange={addToFolder}
placeholder='Add to folder'
data={[
...(folders.data ? folders.data : []).map((folder) => ({
value: String(folder.id),
label: `${folder.id}: ${folder.name}`,
})),
]}
searchable
creatable
getCreateLabel={(query) => `Create folder "${query}"`}
onCreate={createFolder}
/>
</Tooltip>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
) : null}
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
@@ -278,32 +424,6 @@ export default function FileModal({
</ActionIcon>
</Tooltip>
)}
{reducedActions || otherUser ? null : inFolder && !folders.isLoading ? (
<Tooltip
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
>
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
<IconFolderMinus size='1rem' />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label='Add to folder'>
<Select
onChange={addToFolder}
placeholder='Add to folder'
data={[
...(folders.data ? folders.data : []).map((folder) => ({
value: String(folder.id),
label: `${folder.id}: ${folder.name}`,
})),
]}
searchable
creatable
getCreateLabel={(query) => `Create folder "${query}"`}
onCreate={createFolder}
/>
</Tooltip>
)}
</Group>
<Group position='right'>
{reducedActions ? null : (
+3 -17
View File
@@ -32,10 +32,9 @@ export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages = undefined,
refreshImages,
reducedActions = false,
onDash,
otherUser = false,
}) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
@@ -45,7 +44,7 @@ export default function File({
const folders = useFolders();
const refresh = () => {
if (!otherUser) refreshImages();
refreshImages();
folders.refetch();
};
@@ -60,22 +59,9 @@ export default function File({
reducedActions={reducedActions}
exifEnabled={exifEnabled}
compress={onDash}
otherUser={otherUser}
/>
<Card
sx={{
maxWidth: '100%',
height: '100%',
'&:hover': {
filter: 'brightness(0.75)',
},
transition: 'filter 0.2s ease-in-out',
cursor: 'pointer',
}}
shadow='md'
onClick={() => setOpen(true)}
>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
+17
View File
@@ -0,0 +1,17 @@
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { Group, Text } from '@mantine/core';
interface ItemProps extends ComponentPropsWithoutRef<'div'> {
color: string;
label: string;
}
const Item = forwardRef<HTMLDivElement, ItemProps>(({ color, label, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<Text color={color}>{label}</Text>
</Group>
</div>
));
export default Item;
+26
View File
@@ -0,0 +1,26 @@
import { Box, CloseButton, MultiSelectValueProps, rem } from '@mantine/core';
export default function Tag({
label,
onRemove,
color,
...others
}: MultiSelectValueProps & { color: string }) {
return (
<div {...others}>
<Box
sx={(theme) => ({
display: 'flex',
cursor: 'default',
alignItems: 'center',
backgroundColor: color,
paddingLeft: theme.spacing.xs,
borderRadius: theme.radius.sm,
})}
>
<Box sx={{ lineHeight: 1, fontSize: rem(12) }}>{label}</Box>
<CloseButton onMouseDown={onRemove} variant='transparent' size={22} iconSize={14} tabIndex={-1} />
</Box>
</div>
);
}
+15 -10
View File
@@ -4,8 +4,10 @@ import {
Box,
Burger,
Button,
Group,
Header,
Image,
Input,
MediaQuery,
Menu,
Navbar,
@@ -218,14 +220,21 @@ export default function Layout({ children, props }) {
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy token',
message:
"Zipline couldn't copy to your clipboard. Please copy the token manually from the settings page.",
title: 'Unable to copy to clipboard',
message: (
<Text size='sm'>
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
the token manually.
<br />
<Group position='left' spacing='sm'>
<Text>Your token is:</Text>
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
</Group>
</Text>
),
color: 'red',
icon: <IconClipboardCopy size='1rem' />,
});
else
showNotification({
@@ -349,11 +358,7 @@ export default function Layout({ children, props }) {
<Menu.Target>
<Button
leftIcon={
avatar ? (
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
) : (
<IconUserCog size='1rem' />
)
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
}
variant='subtle'
color='gray'
+1 -31
View File
@@ -53,35 +53,6 @@ function Placeholder({ text, Icon, ...props }) {
);
}
function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
if (!file.thumbnail || !mediaPreview)
return <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />;
return (
<Box sx={{ position: 'relative' }}>
<Image
src={file.thumbnail}
sx={{
width: '100%',
height: 'auto',
}}
/>
<Center
sx={{
position: 'absolute',
height: '100%',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay size={48} />
</Center>
</Box>
);
}
export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
const type =
(file.type ?? file.mimetype) === ''
@@ -188,8 +159,7 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
)
) : media ? (
{
// video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
video: <VideoThumbnailPlaceholder file={file} mediaPreview={!disableMediaPreview} />,
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
+38 -25
View File
@@ -12,7 +12,7 @@ import {
import FileModal from 'components/File/FileModal';
import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch';
import { PaginatedFilesOptions, usePaginatedFiles, useRecent } from 'lib/queries/files';
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
import { useStats } from 'lib/queries/stats';
import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes';
@@ -45,24 +45,32 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
})();
}, [page]);
const files = usePaginatedFiles(page, 'none');
// sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
columnAccessor: 'date',
direction: 'asc',
});
const [records, setRecords] = useState(files.data);
const files = usePaginatedFiles(page, {
filter: 'none',
useEffect(() => {
setRecords(files.data);
}, [files.data]);
// only query for correct results if there is more than one page
// otherwise, querying has no effect
...(numFiles > 1
? {
sortBy: sortStatus.columnAccessor as PaginatedFilesOptions['sortBy'],
order: sortStatus.direction,
}
: {}),
});
useEffect(() => {
if (!records || records.length === 0) return;
const sortedRecords = [...records].sort((a, b) => {
if (sortStatus.direction === 'asc') {
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
}
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
});
setRecords(sortedRecords);
}, [sortStatus]);
// file modal on click
const [open, setOpen] = useState(false);
@@ -98,16 +106,22 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
const copyFile = async (file) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
showNotification({
title: 'Copied to clipboard',
message: (
<a
href={`${window.location.protocol}//${window.location.host}${file.url}`}
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
),
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: (
<a
href={`${window.location.protocol}//${window.location.host}${file.url}`}
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
),
icon: <IconClipboardCopy size='1rem' />,
});
};
const viewFile = async (file) => {
@@ -126,7 +140,6 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
reducedActions={false}
exifEnabled={exifEnabled}
compress={compress}
otherUser={false}
/>
)}
@@ -196,7 +209,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
),
},
]}
records={files.data ?? []}
records={records ?? []}
fetching={files.isLoading}
loaderBackgroundBlur={5}
loaderVariant='dots'
+1 -9
View File
@@ -37,17 +37,9 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
})();
}, [page]);
const pages = usePaginatedFiles(page, {
filter: !checked ? 'media' : 'none',
});
const pages = usePaginatedFiles(page, !checked ? 'media' : null);
if (pages.isSuccess && pages.data.length === 0) {
if (page > 1 && numPages > 0) {
setPage(page - 1);
return null;
}
return (
<Center sx={{ flexDirection: 'column' }}>
<Group>
+197
View File
@@ -0,0 +1,197 @@
import {
ActionIcon,
Button,
ColorInput,
Group,
Modal,
Paper,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useDeleteTags, useTags } from 'lib/queries/tags';
import { showNotification } from '@mantine/notifications';
import { IconRefresh, IconTag, IconTags, IconTagsOff } from '@tabler/icons-react';
import { useState } from 'react';
import { colorHash } from 'utils/client';
import useFetch from 'hooks/useFetch';
import { useModals } from '@mantine/modals';
import MutedText from 'components/MutedText';
export function TagCard({ tags, tag }) {
const deleteTags = useDeleteTags();
const modals = useModals();
const deleteTag = () => {
modals.openConfirmModal({
zIndex: 1000,
size: 'auto',
title: (
<Title>
Delete tag <b style={{ color: tag.color }}>{tag.name}</b>?
</Title>
),
children: `This will remove the tag from ${tag.files.length} file${tag.files.length === 1 ? '' : 's'}`,
labels: {
confirm: 'Delete',
cancel: 'Cancel',
},
onCancel() {
modals.closeAll();
},
onConfirm() {
deleteTags.mutate([tag.id], {
onSuccess: () => {
showNotification({
title: 'Tag deleted',
message: `Tag ${tag.name} was deleted`,
color: 'green',
icon: <IconTags size='1rem' />,
});
modals.closeAll();
tags.refetch();
},
});
},
});
};
return (
<Paper
radius='sm'
sx={(t) => ({
backgroundColor: tag.color,
'&:hover': {
backgroundColor: t.fn.darken(tag.color, 0.1),
},
cursor: 'pointer',
})}
px='xs'
onClick={deleteTag}
>
<Group position='apart'>
<Text>
{tag.name} ({tag.files.length})
</Text>
</Group>
</Paper>
);
}
export function CreateTagModal({ tags, open, onClose }) {
const [color, setColor] = useState('');
const [name, setName] = useState('');
const [colorError, setColorError] = useState('');
const [nameError, setNameError] = useState('');
const onSubmit = async (e) => {
e.preventDefault();
setNameError('');
setColorError('');
const n = name.trim();
const c = color.trim();
if (n.length === 0 && c.length === 0) {
setNameError('Name is required');
setColorError('Color is required');
return;
} else if (n.length === 0) {
setNameError('Name is required');
setColorError('');
return;
} else if (c.length === 0) {
setNameError('');
setColorError('Color is required');
return;
}
const data = await useFetch('/api/user/tags', 'POST', {
tags: [
{
name: n,
color: c,
},
],
});
if (!data.error) {
showNotification({
title: 'Tag created',
message: (
<>
Tag <b style={{ color: color }}>{name}</b> was created
</>
),
color: 'green',
icon: <IconTags size='1rem' />,
});
tags.refetch();
onClose();
} else {
showNotification({
title: 'Error creating tag',
message: data.error,
color: 'red',
icon: <IconTagsOff size='1rem' />,
});
}
};
return (
<Modal title={<Title>Create Tag</Title>} size='xs' opened={open} onClose={onClose} zIndex={300}>
<form onSubmit={onSubmit}>
<TextInput
icon={<IconTag size='1rem' />}
label='Name'
value={name}
onChange={(e) => setName(e.currentTarget.value)}
error={nameError}
/>
<ColorInput
dropdownZIndex={301}
label='Color'
value={color}
onChange={setColor}
error={colorError}
rightSection={
<Tooltip label='Generate color from name'>
<ActionIcon variant='subtle' onClick={() => setColor(colorHash(name))} color='primary'>
<IconRefresh size='1rem' />
</ActionIcon>
</Tooltip>
}
/>
<Button type='submit' fullWidth variant='outline' my='sm'>
Create Tag
</Button>
</form>
</Modal>
);
}
export default function TagsModal({ open, onClose }) {
const tags = useTags();
const [createOpen, setCreateOpen] = useState(false);
return (
<>
<CreateTagModal tags={tags} open={createOpen} onClose={() => setCreateOpen(false)} />
<Modal title={<Title>Tags</Title>} size='auto' opened={open} onClose={onClose}>
<MutedText size='sm'>Click on a tag to delete it.</MutedText>
<Stack>
{tags.isSuccess && tags.data.map((tag) => <TagCard key={tag.id} tags={tags} tag={tag} />)}
</Stack>
<Button mt='xl' variant='outline' onClick={() => setCreateOpen(true)} fullWidth compact>
Create Tag
</Button>
</Modal>
</>
);
}
+13 -12
View File
@@ -1,5 +1,5 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
import { IconFileUpload, IconPhotoUp, IconTags } from '@tabler/icons-react';
import File from 'components/File';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
@@ -7,20 +7,15 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal';
import { showNonMediaSelector } from 'lib/recoil/settings';
import { useRecoilState } from 'recoil';
import TagsModal from 'components/pages/Files/TagsModal';
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [checked] = useRecoilState(showNonMediaSelector);
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, {
filter: checked ? 'none' : 'media',
favorite: true,
});
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
const [open, setOpen] = useState(false);
const [pendingOpen, setPendingOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
useEffect(() => {
(async () => {
@@ -31,7 +26,8 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
return (
<>
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
<PendingFilesModal open={pendingOpen} onClose={() => setPendingOpen(false)} />
<TagsModal open={tagsOpen} onClose={() => setTagsOpen(false)} />
<Group mb='md'>
<Title>Files</Title>
@@ -40,10 +36,15 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
</ActionIcon>
<Tooltip label='View pending uploads'>
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
<ActionIcon onClick={() => setPendingOpen(true)} variant='filled' color='primary'>
<IconPhotoUp size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='View tags'>
<ActionIcon onClick={() => setTagsOpen(true)} variant='filled' color='primary'>
<IconTags size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
{favoritePages.isSuccess && favoritePages.data.length ? (
<Accordion
+20 -13
View File
@@ -112,7 +112,7 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
const makePublic = async (folder) => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
public: !folder.public,
public: folder.public ? false : true,
});
if (!res.error) {
@@ -363,18 +363,25 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
showNotification({
title: 'Copied folder link',
message: (
<>
Copied <AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext>{' '}
to clipboard
</>
),
color: 'green',
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied folder link',
message: (
<>
Copied{' '}
<AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext> to
clipboard
</>
),
color: 'green',
icon: <IconClipboardCopy size='1rem' />,
});
}}
>
<IconClipboardCopy size='1rem' />
+70 -70
View File
@@ -30,18 +30,18 @@ import {
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { listViewInvitesSelector } from 'lib/recoil/settings';
import { expireReadToDate, expireText, relativeTime } from 'lib/utils/client';
import { expireText, relativeTime } from 'lib/utils/client';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
const expires = ['30min', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
function CreateInviteModal({ open, setOpen, updateInvites }) {
const form = useForm({
initialValues: {
expires: '30min',
expires: '30m',
count: 1,
},
});
@@ -50,12 +50,26 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100');
const expiresAt = values.expires === 'never' ? null : expireReadToDate(values.expires);
const expiresAt =
values.expires === 'never'
? null
: new Date(
{
'30m': Date.now() + 30 * 60 * 1000,
'1h': Date.now() + 60 * 60 * 1000,
'6h': Date.now() + 6 * 60 * 60 * 1000,
'12h': Date.now() + 12 * 60 * 60 * 1000,
'1d': Date.now() + 24 * 60 * 60 * 1000,
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
}[values.expires]
);
setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', {
expiresAt: `date=${expiresAt.toISOString()}`,
expiresAt,
count: values.count,
});
@@ -85,9 +99,8 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
label='Expires'
id='expires'
{...form.getInputProps('expires')}
maxDropdownHeight={100}
data={[
{ value: '30min', label: '30 minutes' },
{ value: '30m', label: '30 minutes' },
{ value: '1h', label: '1 hour' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
@@ -95,6 +108,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
{ value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' },
{ value: 'never', label: 'Never' },
]}
/>
@@ -183,12 +197,18 @@ export default function Invites() {
const handleCopy = async (invite) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
};
const updateInvites = async () => {
@@ -298,65 +318,45 @@ export default function Invites() {
/>
) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{!ok && !invites.length && (
<>
{[1, 2, 3].map((x) => (
<Skeleton key={x} width='100%' height={100} radius='sm' />
))}
</>
)}
{invites.length && ok ? (
invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
</div>
</Tooltip>
{invites.length
? invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(invite.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
</Card>
))
) : (
<>
<div></div>
<Group>
<div>
<IconTag size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>Create some invites and they will show up here</MutedText>
</div>
</Group>
<div></div>
</>
)}
</Card>
))
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
</SimpleGrid>
)}
</>
-24
View File
@@ -1,11 +1,9 @@
import {
ActionIcon,
Anchor,
Box,
Button,
Card,
ColorInput,
CopyButton,
FileInput,
Group,
Image,
@@ -25,8 +23,6 @@ import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
IconCheck,
IconClipboardCopy,
IconFileExport,
IconFiles,
IconFilesOff,
@@ -93,7 +89,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const [file, setFile] = useState<File | null>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
const [tokenShown, setTokenShown] = useState(false);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
@@ -370,25 +365,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
variables
</MutedText>
<TextInput
rightSection={
<CopyButton value={user.token} timeout={1000}>
{({ copied, copy }) => (
<ActionIcon onClick={copy}>
{copied ? <IconCheck color='green' size='1rem' /> : <IconClipboardCopy size='1rem' />}
</ActionIcon>
)}
</CopyButton>
}
// @ts-ignore (this works even though ts doesn't allow for it)
component='span'
label='Token'
onClick={() => setTokenShown(true)}
>
{tokenShown ? user.token : '[click to reveal]'}
</TextInput>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
<PasswordInput
+12 -6
View File
@@ -27,12 +27,18 @@ export default function MetadataView({ fileId }) {
const copy = (value) => {
clipboard.copy(value);
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <IconClipboardCopy size='1rem' />,
});
};
const searchValue = (value) => {
+24 -56
View File
@@ -1,26 +1,19 @@
import { Anchor, Button, Collapse, Group, Progress, Stack, Text, Title } from '@mantine/core';
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { hideNotification, showNotification, updateNotification } from '@mantine/notifications';
import {
IconClipboardCopy,
IconFileImport,
IconFileTime,
IconFileUpload,
IconFileX,
} from '@tabler/icons-react';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react';
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
import MutedText from 'components/MutedText';
import { invalidateFiles } from 'lib/queries/files';
import { userSelector } from 'lib/recoil/user';
import { expireReadToDate, randomChars } from 'lib/utils/client';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
import AnchorNext from 'components/AnchorNext';
export default function File({ chunks: chunks_config }) {
const router = useRouter();
@@ -35,29 +28,23 @@ export default function File({ chunks: chunks_config }) {
const [options, setOpened, OptionsModal] = useUploadOptions();
const beforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
if (loading) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
return e.returnValue;
}
},
[loading]
);
const beforeUnload = (e: BeforeUnloadEvent) => {
if (loading) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
return e.returnValue;
}
};
const beforeRouteChange = useCallback(
(url: string) => {
if (loading) {
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
if (!confirmed) {
router.events.emit('routeChangeComplete', url);
throw 'Route change aborted';
}
const beforeRouteChange = (url: string) => {
if (loading) {
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
if (!confirmed) {
router.events.emit('routeChangeComplete', url);
throw 'Route change aborted';
}
},
[loading]
);
}
};
useEffect(() => {
const listener = (e: ClipboardEvent) => {
@@ -75,10 +62,10 @@ export default function File({ chunks: chunks_config }) {
};
document.addEventListener('paste', listener);
window.addEventListener('beforeunload', beforeUnload, true);
window.addEventListener('beforeunload', beforeUnload);
router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload, true);
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
@@ -138,34 +125,15 @@ export default function File({ chunks: chunks_config }) {
updateNotification({
id: 'upload-chunked',
title: 'Finalizing partial upload',
message: (
<Text>
The upload has been offloaded, and will complete in the background.
<br />
<Anchor
component='span'
onClick={() => {
hideNotification('upload-chunked');
clipboard.copy(json.files[0]);
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={json.files[0]}>{json.files[0]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
}}
>
Click here to copy the URL while it&lsquo;s being processed.
</Anchor>
</Text>
),
message:
'The upload has been offloaded, and will complete in the background. You can see processing files in the files tab.',
icon: <IconFileTime size='1rem' />,
color: 'green',
autoClose: false,
autoClose: true,
});
invalidateFiles();
setFiles([]);
setProgress(100);
setLoading(false);
setTimeout(() => setProgress(0), 1000);
}
+12 -6
View File
@@ -7,12 +7,18 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
const open = (idx: number) => window.open(files[idx], '_blank');
const copy = (idx: number) => {
clipboard.copy(files[idx]);
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
};
modals.openModal({
+12 -6
View File
@@ -169,12 +169,18 @@ export default function Urls() {
const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
};
const urlDelete = useURLDelete();
-82
View File
@@ -1,82 +0,0 @@
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
import { File } from '@prisma/client';
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
import FileComponent from 'components/File';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { userSelector } from 'lib/recoil/user';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
type UserFiles = {
id: number;
username: string;
files?: File[];
error?: unknown;
};
export default function UserFiles({ userId, disableMediaPreview, exifEnabled, compress }) {
const [currentUser, viewUser] = useState<UserFiles>({ id: 0, username: 'user' });
const [self] = useRecoilState(userSelector);
const { push } = useRouter();
useEffect(() => {
if (self.id == userId) push('/dashboard/files');
(async () => {
const user: UserFiles = await useFetch(`/api/user/${userId}`);
if (!user.error) {
viewUser(user);
} else {
push('/dashboard');
}
})();
}, [userId]);
if (!currentUser.files || currentUser.files.length === 0) {
return (
<Center sx={{ flexDirection: 'column' }}>
<Group>
<div>
<IconFile size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>
{currentUser.username} seems to have not uploaded any files... yet
</MutedText>
</div>
<Button size='md' onClick={() => push('/dashboard/users')}>
Head back?
</Button>
</Group>
</Center>
);
}
return (
<>
<Group mb='md'>
<ActionIcon size='lg' onClick={() => push('/dashboard/users')} color='primary'>
<IconArrowLeft />
</ActionIcon>
<Title>{currentUser.username}&apos;s Files</Title>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{currentUser.files.map((file) => (
<div key={file.id}>
<FileComponent
image={file}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
onDash={compress}
otherUser={true}
/>
</div>
))}
</SimpleGrid>
</>
);
}
-12
View File
@@ -6,7 +6,6 @@ import type { User } from '@prisma/client';
import {
IconClipboardCopy,
IconEdit,
IconExternalLink,
IconGridDots,
IconList,
IconUserExclamation,
@@ -117,10 +116,6 @@ export default function Users() {
}
};
const openUser = async (user) => {
await router.push(`/dashboard/users/${user.id}`);
};
useEffect(() => {
updateUsers();
}, []);
@@ -186,13 +181,6 @@ export default function Users() {
<IconEdit size='1rem' />
</ActionIcon>
</Tooltip>
{(!self.superAdmin && user.superAdmin) || (self.superAdmin && user.superAdmin) ? null : (
<Tooltip label='Open user'>
<ActionIcon color='cyan' onClick={() => openUser(user)}>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
)}
</Group>
),
},
-7
View File
@@ -10,7 +10,6 @@ export interface ConfigCore {
stats_interval: number;
invites_interval: number;
thumbnails_interval: number;
}
export interface ConfigCompression {
@@ -121,15 +120,9 @@ export interface ConfigFeatures {
headless: boolean;
default_avatar: string;
robots_txt: string;
thumbnails: boolean;
}
export interface ConfigOAuth {
bypass_local_login: boolean;
github_client_id?: string;
github_client_secret?: string;
-9
View File
@@ -63,11 +63,8 @@ export default function readConfig() {
map('CORE_PORT', 'number', 'core.port'),
map('CORE_DATABASE_URL', 'string', 'core.database_url'),
map('CORE_LOGGER', 'boolean', 'core.logger'),
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'),
map('CORE_THUMBNAILS_INTERVAL', 'number', 'core.thumbnails_interval'),
map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'),
map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'),
map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'),
@@ -139,8 +136,6 @@ export default function readConfig() {
map('DISCORD_SHORTEN_EMBED_THUMBNAIL', 'boolean', 'discord.shorten.embed.thumbnail'),
map('DISCORD_SHORTEN_EMBED_TIMESTAMP', 'boolean', 'discord.shorten.embed.timestamp'),
map('OAUTH_BYPASS_LOCAL_LOGIN', 'boolean', 'oauth.bypass_local_login'),
map('OAUTH_GITHUB_CLIENT_ID', 'string', 'oauth.github_client_id'),
map('OAUTH_GITHUB_CLIENT_SECRET', 'string', 'oauth.github_client_secret'),
@@ -161,10 +156,6 @@ export default function readConfig() {
map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'),
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
map('CHUNKS_ENABLED', 'boolean', 'chunks.enabled'),
+4 -11
View File
@@ -4,7 +4,7 @@ import { inspect } from 'util';
import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { join } from 'path';
const discord_content = s
.object({
@@ -35,9 +35,8 @@ const validator = s.object({
port: s.number.default(3000),
database_url: s.string,
logger: s.boolean.default(false),
stats_interval: s.number.default(1800), // 30m
invites_interval: s.number.default(1800), // 30m
thumbnails_interval: s.number.default(600), // 10m
stats_interval: s.number.default(1800),
invites_interval: s.number.default(1800),
compression: s
.object({
enabled: s.boolean.default(false),
@@ -54,7 +53,7 @@ const validator = s.object({
type: s.enum('local', 's3', 'supabase').default('local'),
local: s
.object({
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
directory: s.string.default('./uploads'),
})
.default({
directory: './uploads',
@@ -169,8 +168,6 @@ const validator = s.object({
.nullish.default(null),
oauth: s
.object({
bypass_local_login: s.boolean.default(false),
github_client_id: s.string.nullable.default(null),
github_client_secret: s.string.nullable.default(null),
@@ -190,8 +187,6 @@ const validator = s.object({
user_registration: s.boolean.default(false),
headless: s.boolean.default(false),
default_avatar: s.string.nullable.default(null),
robots_txt: s.boolean.default(false),
thumbnails: s.boolean.default(false),
})
.default({
invites: false,
@@ -201,8 +196,6 @@ const validator = s.object({
user_registration: false,
headless: false,
default_avatar: null,
robots_txt: false,
thumbnails: false,
}),
chunks: s
.object({
+6 -9
View File
@@ -11,23 +11,22 @@ export class Local extends Datasource {
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(this.path, file), data);
await writeFile(join(process.cwd(), this.path, file), data);
}
public async delete(file: string): Promise<void> {
await rm(join(this.path, file));
await rm(join(process.cwd(), this.path, file));
}
public async clear(): Promise<void> {
const files = await readdir(this.path);
const files = await readdir(join(process.cwd(), this.path));
for (let i = 0; i !== files.length; ++i) {
await rm(join(this.path, files[i]));
await rm(join(process.cwd(), this.path, files[i]));
}
}
public get(file: string): ReadStream {
const full = join(this.path, file);
const full = join(process.cwd(), this.path, file);
if (!existsSync(full)) return null;
try {
@@ -38,9 +37,7 @@ export class Local extends Datasource {
}
public async size(file: string): Promise<number> {
const full = join(this.path, file);
if (!existsSync(full)) return 0;
const stats = await stat(full);
const stats = await stat(join(process.cwd(), this.path, file));
return stats.size;
}
+4 -4
View File
@@ -50,22 +50,22 @@ export class S3 extends Datasource {
}
public size(file: string): Promise<number> {
return new Promise((res) => {
return new Promise((res, rej) => {
this.s3.statObject(this.config.bucket, file, (err, stat) => {
if (err) res(0);
if (err) rej(err);
else res(stat.size);
});
});
}
public async fullSize(): Promise<number> {
return new Promise((res) => {
return new Promise((res, rej) => {
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
let size = 0;
objects.on('data', (item) => (size += item.size));
objects.on('end', (err) => {
if (err) res(0);
if (err) rej(err);
else res(size);
});
});
+2 -2
View File
@@ -63,13 +63,13 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
thumbnail:
isImage && parsed.embed.thumbnail
? {
url: raw_link,
url: parsed.url,
}
: null,
image:
isImage && parsed.embed.image
? {
url: raw_link,
url: parsed.url,
}
: null,
},
+1 -4
View File
@@ -2,7 +2,6 @@ import date from './date';
import gfycat from './gfycat';
import random from './random';
import uuid from './uuid';
import { parse } from 'path';
export type NameFormat = 'random' | 'date' | 'uuid' | 'name' | 'gfycat';
export const NameFormats: NameFormat[] = ['random', 'date', 'uuid', 'name', 'gfycat'];
@@ -15,9 +14,7 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
case 'uuid':
return uuid();
case 'name':
const { name } = parse(originalName);
return name;
return originalName.split('.')[0];
case 'gfycat':
return gfycat();
default:
-2
View File
@@ -16,7 +16,6 @@ export type ServerSideProps = {
user_registration: boolean;
oauth_registration: boolean;
oauth_providers: string;
bypass_local_login: boolean;
chunks_size: number;
max_size: number;
totp_enabled: boolean;
@@ -61,7 +60,6 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
user_registration: config.features.user_registration,
oauth_registration: config.features.oauth_registration,
oauth_providers: JSON.stringify(oauth_providers),
bypass_local_login: config.oauth?.bypass_local_login ?? false,
chunks_size: config.chunks.chunks_size,
max_size: config.chunks.max_size,
totp_enabled: config.mfa.totp_enabled,
+6 -16
View File
@@ -33,23 +33,13 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
);
});
};
export type PaginatedFilesOptions = {
filter: 'media' | 'none';
favorite: boolean;
sortBy: 'createdAt' | 'views' | 'expiresAt' | 'size' | 'name' | 'mimetype';
order: 'asc' | 'desc';
};
export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFilesOptions>) => {
const queryString = new URLSearchParams({
export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
const queryBuilder = new URLSearchParams({
page: Number(page || '1').toString(),
filter: options?.filter ?? 'none',
// ...(options?.favorite !== null && { favorite: options?.favorite?.toString() }),
favorite: options.favorite ? 'true' : '',
sortBy: options.sortBy ?? '',
order: options.order ?? '',
}).toString();
filter,
...(favorite !== null && { favorite: favorite.toString() }),
});
const queryString = queryBuilder.toString();
return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
return fetch('/api/user/paged?' + queryString)
+168
View File
@@ -0,0 +1,168 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import queryClient from 'lib/queries/client';
export type UserTagsResponse = {
id: string;
name: string;
color: string;
files: {
id: string;
}[];
};
export type TagsRequest = {
id?: string;
name?: string;
color?: string;
};
export const useTags = () => {
return useQuery<UserTagsResponse[]>(['tags'], async () => {
return fetch('/api/user/tags')
.then((res) => res.json() as Promise<UserTagsResponse[]>)
.then((data) => data);
});
};
export const useFileTags = (id: string) => {
return useQuery<UserTagsResponse[]>(['tags', id], async () => {
return fetch(`/api/user/file/${id}/tags`)
.then((res) => res.json() as Promise<UserTagsResponse[]>)
.then((data) => data);
});
};
export const useUpdateFileTags = (id: string) => {
return useMutation(
(tags: TagsRequest[]) =>
fetch(`/api/user/file/${id}/tags`, {
method: 'POST',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags', id]);
queryClient.refetchQueries(['files']);
},
}
);
};
export const useDeleteFileTags = (id: string) => {
return useMutation(
(tags: string[]) =>
fetch(`/api/user/file/${id}/tags`, {
method: 'DELETE',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags', id]);
},
}
);
};
export const useDeleteTags = () => {
return useMutation(
(tags: string[]) =>
fetch('/api/user/tags', {
method: 'DELETE',
body: JSON.stringify({ tags }),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json()),
{
onSuccess: () => {
queryClient.refetchQueries(['tags']);
queryClient.refetchQueries(['files']);
},
}
);
};
// export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
// const queryBuilder = new URLSearchParams({
// page: Number(page || '1').toString(),
// filter,
// ...(favorite !== null && { favorite: favorite.toString() }),
// });
// const queryString = queryBuilder.toString();
//
// return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
// return fetch('/api/user/paged?' + queryString)
// .then((res) => res.json() as Promise<UserFilesResponse[]>)
// .then((data) =>
// data.map((x) => ({
// ...x,
// createdAt: new Date(x.createdAt),
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
// }))
// );
// });
// };
//
// export const useRecent = (filter?: string) => {
// return useQuery<UserFilesResponse[]>(['recent', filter], async () => {
// return fetch(`/api/user/recent?filter=${encodeURIComponent(filter)}`)
// .then((res) => res.json())
// .then((data) =>
// data.map((x) => ({
// ...x,
// createdAt: new Date(x.createdAt),
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
// }))
// );
// });
// };
//
// export function useFileDelete() {
// // '/api/user/files', 'DELETE', { id: image.id }
// return useMutation(
// async (id: string) => {
// return fetch('/api/user/files', {
// method: 'DELETE',
// body: JSON.stringify({ id }),
// headers: {
// 'content-type': 'application/json',
// },
// }).then((res) => res.json());
// },
// {
// onSuccess: () => {
// queryClient.refetchQueries(['files']);
// },
// }
// );
// }
//
// export function useFileFavorite() {
// // /api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }
// return useMutation(
// async (data: { id: string; favorite: boolean }) => {
// return fetch('/api/user/files', {
// method: 'PATCH',
// body: JSON.stringify(data),
// headers: {
// 'content-type': 'application/json',
// },
// }).then((res) => res.json());
// },
// {
// onSuccess: () => {
// queryClient.refetchQueries(['files']);
// },
// }
// );
// }
//
// export function invalidateFiles() {
// return queryClient.invalidateQueries(['files', 'recent', 'stats']);
// }
+16
View File
@@ -0,0 +1,16 @@
export function exclude<T, Key extends keyof T>(obj: T, keys: Key[]): Omit<T, Key> {
for (const key of keys) {
delete obj[key];
}
return obj;
}
export function pick<T, Key extends keyof T>(obj: T, keys: Key[]): Pick<T, Key> {
const newObj: unknown = {};
for (const key of keys) {
newObj[key] = obj[key];
}
return newObj as Pick<T, Key>;
}
+37 -44
View File
@@ -1,10 +1,10 @@
import { File } from '@prisma/client';
import { ExifTool, Tags } from 'exiftool-vendored';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';
import { ExifTool, Tags } from 'exiftool-vendored';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
const logger = Logger.get('exif');
@@ -43,54 +43,47 @@ export async function removeGPSData(image: File): Promise<void> {
await new Promise((resolve) => writeStream.on('finish', resolve));
logger.debug(`removing GPS data from ${file}`);
try {
await exiftool.write(file, {
GPSVersionID: null,
GPSAltitude: null,
GPSAltitudeRef: null,
GPSAreaInformation: null,
GPSDateStamp: null,
GPSDateTime: null,
GPSDestBearing: null,
GPSDestBearingRef: null,
GPSDestDistance: null,
GPSDestLatitude: null,
GPSDestLatitudeRef: null,
GPSDestLongitude: null,
GPSDestLongitudeRef: null,
GPSDifferential: null,
GPSDOP: null,
GPSHPositioningError: null,
GPSImgDirection: null,
GPSImgDirectionRef: null,
GPSLatitude: null,
GPSLatitudeRef: null,
GPSLongitude: null,
GPSLongitudeRef: null,
GPSMapDatum: null,
GPSPosition: null,
GPSProcessingMethod: null,
GPSSatellites: null,
GPSSpeed: null,
GPSSpeedRef: null,
GPSStatus: null,
GPSTimeStamp: null,
GPSTrack: null,
GPSTrackRef: null,
});
} catch (e) {
logger.debug(`removing temp file: ${file}`);
await rm(file);
return;
}
await exiftool.write(file, {
GPSVersionID: null,
GPSAltitude: null,
GPSAltitudeRef: null,
GPSAreaInformation: null,
GPSDateStamp: null,
GPSDateTime: null,
GPSDestBearing: null,
GPSDestBearingRef: null,
GPSDestDistance: null,
GPSDestLatitude: null,
GPSDestLatitudeRef: null,
GPSDestLongitude: null,
GPSDestLongitudeRef: null,
GPSDifferential: null,
GPSDOP: null,
GPSHPositioningError: null,
GPSImgDirection: null,
GPSImgDirectionRef: null,
GPSLatitude: null,
GPSLatitudeRef: null,
GPSLongitude: null,
GPSLongitudeRef: null,
GPSMapDatum: null,
GPSPosition: null,
GPSProcessingMethod: null,
GPSSatellites: null,
GPSSpeed: null,
GPSSpeedRef: null,
GPSStatus: null,
GPSTimeStamp: null,
GPSTrack: null,
GPSTrackRef: null,
});
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file);
await datasource.save(image.name, buffer);
logger.debug(`removing temp file: ${file}`);
await rm(file);
await unlink(file);
await exiftool.end(true);
+3 -9
View File
@@ -2,10 +2,8 @@ import { Button, Stack, Title, Tooltip } from '@mantine/core';
import MutedText from 'components/MutedText';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
export default function FiveHundred() {
const { asPath } = useRouter();
return (
<>
<Head>
@@ -26,13 +24,9 @@ export default function FiveHundred() {
<Tooltip label={"Take a look at Zipline's logs and the browser console for more info"}>
<MutedText>Internal server error</MutedText>
</Tooltip>
{asPath === '/dashboard' ? (
<Button onClick={() => window.location.reload()}>Attempt Refresh</Button>
) : (
<Button component={Link} href='/dashboard'>
Head to the Dashboard
</Button>
)}
<Button component={Link} href='/dashboard'>
Head to the Dashboard
</Button>
</Stack>
</>
);
+1 -1
View File
@@ -6,7 +6,7 @@ const logger = Logger.get('admin');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
try {
const { orphaned } = req.body;
const { datasource, orphaned } = req.body;
if (orphaned) {
const { count } = await prisma.file.deleteMany({
where: {
-1
View File
@@ -7,7 +7,6 @@ import { extname } from 'path';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id, password } = req.query;
if (isNaN(Number(id))) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: {
+5 -3
View File
@@ -3,7 +3,6 @@ import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
import prisma from 'lib/prisma';
import { randomChars } from 'lib/util';
import { parseExpiry } from 'lib/utils/client';
const logger = Logger.get('invite');
@@ -16,8 +15,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
count: number;
};
const expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
const expiry = expiresAt ? new Date(expiresAt) : null;
if (expiry) {
if (!expiry.getTime()) return res.badRequest('invalid date');
if (expiry.getTime() < Date.now()) return res.badRequest('date is in the past');
}
const counts = count ? count : 1;
if (counts > 1) {
+7 -39
View File
@@ -12,7 +12,7 @@ import { createInvisImage, hashPassword } from 'lib/util';
import { parseExpiry } from 'lib/utils/client';
import { removeGPSData } from 'lib/utils/exif';
import multer from 'multer';
import { join, parse } from 'path';
import { join } from 'path';
import sharp from 'sharp';
import { Worker } from 'worker_threads';
@@ -109,38 +109,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
await writeFile(tempFile, req.files[0].buffer);
if (lastchunk) {
const fileName = await formatFileName(format, filename);
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
const file = await prisma.file.create({
data: {
name: `${fileName}${ext ? '.' : ''}${ext}`,
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
userId: user.id,
originalName: req.headers['original-name'] ? filename ?? null : null,
},
});
let domain;
if (req.headers['override-domain']) {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
} else if (user.domains.length) {
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
} else {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
}
const responseUrl = `${domain}${
zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'
}${encodeURI(file.name)}`;
new Worker('./dist/worker/upload.js', {
workerData: {
user,
file: {
id: file.id,
filename: file.name,
mimetype: file.mimetype,
filename,
mimetype,
identifier,
lastchunk,
totalBytes: total,
@@ -148,6 +122,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
response: {
expiresAt: expiry,
format,
imageCompressionPercent,
fileMaxViews,
},
headers: req.headers,
@@ -156,7 +131,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.json({
pending: true,
files: [responseUrl],
});
}
@@ -226,7 +200,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let mimetype = file.mimetype;
if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) {
const ext = parse(file.originalname).ext.replace('.', '');
const ext = file.originalname.split('.').pop();
const mime = await guess(ext);
if (!mime) response.assumed_mimetype = false;
@@ -252,8 +226,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
if (typeof req.headers.zws !== 'undefined' && (req.headers.zws as string).toLowerCase().match('true'))
invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
if (compressionUsed) {
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
@@ -282,12 +255,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
response.files.push(responseUrl);
if (zconfig.discord?.upload) {
await sendUpload(
user,
fileUpload,
`${domain}/r/${invis ? invis.invis : encodeURI(fileUpload.name)}`,
responseUrl
);
await sendUpload(user, fileUpload, `${domain}/r/${invis ? invis.invis : fileUpload.name}`, responseUrl);
}
if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) {
-26
View File
@@ -3,8 +3,6 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client';
import { formatRootUrl } from 'lib/utils/urls';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('user');
@@ -16,14 +14,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: {
id: Number(id),
},
include: {
files: {
include: {
thumbnail: true,
},
},
Folder: true,
},
});
if (!target) return res.notFound('user not found');
@@ -185,22 +175,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} else {
delete target.password;
if (user.superAdmin && target.superAdmin) {
delete target.files;
return res.json(target);
}
if (user.administrator && !user.superAdmin && (target.administrator || target.superAdmin)) {
delete target.files;
return res.json(target);
}
for (const file of target.files) {
(file as unknown as { url: string }).url = formatRootUrl(zconfig.uploader.route, file.name);
if (file.thumbnail) {
(file.thumbnail as unknown as string) = formatRootUrl('/r', file.thumbnail.name);
}
}
return res.json(target);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { pick } from 'utils/db';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let { id } = req.query as { id: string | number };
if (!id) return res.badRequest('no id');
id = Number(id);
if (isNaN(id)) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: { id, userId: user.id },
include: {
tags: true,
invisible: true,
folder: true,
},
});
if (!file) return res.notFound('file not found or not owned by user');
if (req.method === 'DELETE') {
return res.badRequest('file deletions must be done at `DELETE /api/user/files`');
} else {
// @ts-ignore
if (file.password) file.password = true;
if (req.query.pick) {
const picks = (req.query.pick as string).split(',') as (keyof typeof file)[];
return res.json(pick(file, picks));
}
return res.json(file);
}
}
export default withZipline(handler, {
methods: ['GET', 'DELETE'],
user: true,
});
+104
View File
@@ -0,0 +1,104 @@
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let { id } = req.query as { id: string | number };
if (!id) return res.badRequest('no id');
id = Number(id);
if (isNaN(id)) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: { id, userId: user.id },
select: {
tags: true,
},
});
if (!file) return res.notFound('file not found or not owned by user');
if (req.method === 'DELETE') {
const { tags } = req.body as {
tags: string[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
disconnect: tags.map((tag) => ({ id: tag })),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
} else if (req.method === 'PATCH') {
const { tags } = req.body as {
tags: string[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
connect: tags.map((tag) => ({ id: tag })),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
} else if (req.method === 'POST') {
const { tags } = req.body as {
tags: {
name?: string;
color?: string;
id?: string;
}[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
// if the tag has an id, it means it already exists, so we just connect it
// if it doesn't have an id, we create it and then connect it
const nFile = await prisma.file.update({
where: { id },
data: {
tags: {
connectOrCreate: tags.map((tag) => ({
where: { id: tag.id ?? '' },
create: {
name: tag.name,
color: tag.color,
},
})),
},
},
select: {
tags: true,
},
});
return res.json(nFile.tags);
}
return res.json(file.tags);
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
user: true,
});
+4 -69
View File
@@ -14,14 +14,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: {
userId: user.id,
},
include: {
thumbnail: true,
},
});
for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name);
if (files[i].thumbnail?.name) await datasource.delete(files[i].thumbnail.name);
}
const { count } = await prisma.file.deleteMany({
@@ -35,49 +31,15 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} else {
if (!req.body.id) return res.badRequest('no file id');
let file = await prisma.file.findFirst({
const file = await prisma.file.delete({
where: {
id: req.body.id,
userId: user.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
thumbnail: true,
},
});
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
file = await prisma.file.delete({
where: {
id: req.body.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
thumbnail: true,
},
});
await datasource.delete(file.name);
if (file.thumbnail?.name) await datasource.delete(file.thumbnail.name);
logger.info(
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`
);
logger.info(`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id})`);
// @ts-ignore
if (file.password) file.password = true;
@@ -89,33 +51,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let file;
if (req.body.favorite !== null) {
file = await prisma.file.findFirst({
where: {
id: req.body.id,
userId: user.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
},
});
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
if (req.body.favorite !== null)
file = await prisma.file.update({
where: { id: req.body.id },
data: {
favorite: req.body.favorite,
},
});
}
// @ts-ignore
if (file.password) file.password = true;
return res.json(file);
@@ -140,8 +83,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: number;
views: number;
size: number;
originalName: string;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({
where: {
userId: user.id,
@@ -161,17 +102,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true,
maxViews: true,
size: true,
originalName: true,
thumbnail: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
}
if (req.query.filter && req.query.filter === 'media')
+2 -3
View File
@@ -58,13 +58,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(folder);
} else {
if (req.query.files instanceof Array) req.query.files = req.query.files[0];
const folders = await prisma.folder.findMany({
where: {
userId: user.id,
},
select: {
files: ((req.query.files as string) ?? 'false').toLowerCase() === 'true',
files: !!req.query.files,
id: true,
name: true,
userId: true,
@@ -77,7 +76,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
});
if (((req.query.files as string) ?? 'false').toLowerCase() === 'true') {
if (req.query.files) {
for (let i = 0; i !== folders.length; ++i) {
const folder = folders[i];
for (let j = 0; j !== folders[i].files.length; ++j) {
+14 -22
View File
@@ -1,4 +1,4 @@
import zconfig from 'lib/config';
import config from 'lib/config';
import Logger from 'lib/logger';
import { discord_auth, github_auth, google_auth } from 'lib/oauth';
import prisma from 'lib/prisma';
@@ -18,7 +18,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({
error: 'oauth token expired',
redirect_uri: github_auth.oauth_url(zconfig.oauth.github_client_id),
redirect_uri: github_auth.oauth_url(config.oauth.github_client_id),
});
}
} else if (user.oauth.find((o) => o.provider === 'DISCORD')) {
@@ -35,8 +35,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({
error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url(
zconfig.oauth.discord_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
config.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
),
});
}
@@ -47,8 +47,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: zconfig.oauth.discord_client_id,
client_secret: zconfig.oauth.discord_client_secret,
client_id: config.oauth.discord_client_id,
client_secret: config.oauth.discord_client_secret,
grant_type: 'refresh_token',
refresh_token: provider.refresh,
}),
@@ -59,8 +59,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({
error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url(
zconfig.oauth.discord_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
config.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
),
});
}
@@ -90,8 +90,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({
error: 'oauth token expired',
redirect_uri: google_auth.oauth_url(
zconfig.oauth.google_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
config.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
),
});
}
@@ -101,8 +101,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: zconfig.oauth.google_client_id,
client_secret: zconfig.oauth.google_client_secret,
client_id: config.oauth.google_client_id,
client_secret: config.oauth.google_client_secret,
grant_type: 'refresh_token',
refresh_token: provider.refresh,
}),
@@ -113,8 +113,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({
error: 'oauth token expired',
redirect_uri: google_auth.oauth_url(
zconfig.oauth.google_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
config.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
),
});
}
@@ -241,14 +241,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}
}
export const config = {
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default withZipline(handler, {
methods: ['GET', 'PATCH'],
user: true,
+3 -33
View File
@@ -1,5 +1,3 @@
import { Prisma } from '@prisma/client';
import { s } from '@sapphire/shapeshift';
import config from 'lib/config';
import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls';
@@ -7,27 +5,12 @@ import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/wi
const pageCount = 16;
const sortByValidator = s.enum(
...([
'createdAt',
'views',
'expiresAt',
'size',
'name',
'mimetype',
] satisfies (keyof Prisma.FileOrderByWithRelationInput)[])
);
const orderValidator = s.enum('asc', 'desc');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { page, filter, count, favorite, ...rest } = req.query as {
const { page, filter, count, favorite } = req.query as {
page: string;
filter: string;
count: string;
favorite: string;
sortBy: string;
order: string;
};
const where = {
@@ -50,7 +33,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
],
}),
} satisfies Prisma.FileWhereInput;
};
if (count) {
const count = await prisma.file.count({
@@ -65,14 +48,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!page) return res.badRequest('no page');
if (isNaN(Number(page))) return res.badRequest('page is not a number');
// validate sortBy
const sortBy = sortByValidator.run(rest.sortBy || 'createdAt');
if (!sortBy.isOk()) return res.badRequest('invalid sortBy option');
// validate order
const order = orderValidator.run(rest.order || 'desc');
if (!sortBy.isOk()) return res.badRequest('invalid order option');
const files: {
favorite: boolean;
createdAt: Date;
@@ -85,11 +60,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: number;
size: number;
password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({
where,
orderBy: {
[sortBy.value]: order.value,
createdAt: 'desc',
},
select: {
createdAt: true,
@@ -103,7 +77,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true,
size: true,
password: true,
thumbnail: true,
},
skip: page ? (Number(page) - 1) * pageCount : undefined,
take: page ? pageCount : undefined,
@@ -114,9 +87,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (file.password) file.password = true;
(file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
}
return res.json(files);
-4
View File
@@ -27,15 +27,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true,
size: true,
favorite: true,
thumbnail: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
}
if (req.query.filter && req.query.filter === 'media')
+66
View File
@@ -0,0 +1,66 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const tags = await prisma.tag.findMany({
where: {
files: {
every: {
userId: user.id,
},
},
},
include: {
files: {
select: {
id: true,
},
},
},
});
if (req.method === 'DELETE') {
const { tags: tagIds } = req.body as {
tags: string[];
};
if (!tagIds) return res.badRequest('no tags');
if (!tagIds.length) return res.badRequest('no tags');
const nTags = await prisma.tag.deleteMany({
where: {
id: {
in: tagIds,
},
},
});
return res.json(nTags);
} else if (req.method === 'POST') {
const { tags } = req.body as {
tags: {
name: string;
color: string;
}[];
};
if (!tags) return res.badRequest('no tags');
if (!tags.length) return res.badRequest('no tags');
const nTags = await prisma.tag.createMany({
data: tags.map((tag) => ({
name: tag.name,
color: tag.color,
})),
});
return res.json(nTags);
}
return res.json(tags);
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'],
user: true,
});
+33 -49
View File
@@ -22,13 +22,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login({
title,
user_registration,
oauth_registration,
bypass_local_login,
oauth_providers: unparsed,
}) {
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) {
const router = useRouter();
// totp modal
@@ -40,9 +34,6 @@ export default function Login({
const oauth_providers = JSON.parse(unparsed);
const show_local_login =
router.query.local === 'true' || !(bypass_local_login && oauth_providers?.length > 0);
const icons = {
GitHub: IconBrandGithub,
Discord: IconBrandDiscordFilled,
@@ -108,12 +99,6 @@ export default function Login({
useEffect(() => {
(async () => {
// if the user includes `local=true` as a query param, show the login form
// otherwise, redirect to the oauth login if there is only one registered provider
if (bypass_local_login && oauth_providers?.length === 1 && router.query.local !== 'true') {
await router.push(oauth_providers[0].url);
}
const a = await fetch('/api/user');
if (a.ok) await router.push('/dashboard');
})();
@@ -167,7 +152,7 @@ export default function Login({
<Center sx={{ height: '100vh' }}>
<Card radius='md'>
<Title size={30} align='left'>
{bypass_local_login ? ` Login to ${title} with` : title}
{title}
</Title>
{oauth_registration && (
@@ -180,7 +165,7 @@ export default function Login({
variant='outline'
radius='md'
fullWidth
leftIcon={<Icon size='1rem' />}
leftIcon={<Icon height={'15'} width={'15'} />}
my='xs'
component={Link}
href={url}
@@ -189,42 +174,41 @@ export default function Login({
</Button>
))}
</Group>
{show_local_login && <Divider my='xs' label='or' labelPosition='center' />}
<Divider my='xs' label='or' labelPosition='center' />
</>
)}
{show_local_login && (
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput
my='xs'
radius='md'
size='md'
id='username'
label='Username'
{...form.getInputProps('username')}
/>
<PasswordInput
my='xs'
radius='md'
size='md'
id='password'
label='Password'
{...form.getInputProps('password')}
/>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput
my='xs'
radius='md'
size='md'
id='username'
label='Username'
{...form.getInputProps('username')}
/>
<PasswordInput
my='xs'
radius='md'
size='md'
id='password'
label='Password'
{...form.getInputProps('password')}
/>
<Group position='apart'>
{user_registration && (
<Anchor size='xs' href='/auth/register' component={Link}>
Don&apos;t have an account? Register
</Anchor>
)}
<Group position='apart'>
{user_registration && (
<Anchor size='xs' href='/auth/register' component={Link}>
Don&apos;t have an account? Register
</Anchor>
)}
<Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}>
Login
</Button>
</Group>
</form>
)}
<Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}>
Login
</Button>
</Group>
</form>
</Card>
</Center>
</>
@@ -10,7 +10,7 @@ export default function UsersPage(props) {
if (loading) return <LoadingOverlay visible={loading} />;
const title = `${props.title} - Users`;
const title = `${props.title} - User`;
return (
<>
<Head>
-42
View File
@@ -1,42 +0,0 @@
import { LoadingOverlay } from '@mantine/core';
import Layout from 'components/Layout';
import UserFiles from 'components/pages/Users/UserFiles';
import useLogin from 'hooks/useLogin';
import Head from 'next/head';
import { getServerSideProps as middlewareProps } from 'middleware/getServerSideProps';
import { GetServerSideProps } from 'next';
export default function UsersId(props) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible={loading} />;
const title = `${props.title} - User - ${props.userId}`;
return (
<>
<Head>
<title>{title}</title>
</Head>
<Layout props={props}>
<UserFiles
userId={props.userId}
disableMediaPreview={props.disable_media_preview}
exifEnabled={props.exif_enabled}
compress={props.compress}
/>
</Layout>
</>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params as { id: string };
// @ts-ignore
const { props } = await middlewareProps(context);
return {
props: {
userId: id,
...props,
},
};
};
+15 -52
View File
@@ -1,5 +1,5 @@
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import type { File, Thumbnail } from '@prisma/client';
import type { File } from '@prisma/client';
import AnchorNext from 'components/AnchorNext';
import exts from 'lib/exts';
import prisma from 'lib/prisma';
@@ -10,21 +10,18 @@ import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import zconfig from 'lib/config';
export default function EmbeddedFile({
file,
user,
pass,
prismRender,
host,
compress,
}: {
file: File & { imageProps?: HTMLImageElement; thumbnail: Thumbnail };
file: File & { imageProps?: HTMLImageElement };
user: UserExtended;
pass: boolean;
prismRender: boolean;
host: string;
compress?: boolean;
}) {
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
@@ -102,36 +99,26 @@ export default function EmbeddedFile({
{file.mimetype.startsWith('image') && (
<>
<meta property='og:type' content='image' />
<meta property='og:image' itemProp='image' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:image' itemProp='image' content={`/r/${file.name}`} />
<meta property='og:url' content={`/r/${file.name}`} />
<meta property='og:image:width' content={file.imageProps?.naturalWidth.toString()} />
<meta property='og:image:height' content={file.imageProps?.naturalHeight.toString()} />
<meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:image' content={`${host}/r/${file.name}`} />
<meta property='twitter:title' content={file.name} />
</>
)}
{file.mimetype.startsWith('video') && (
<>
<meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`/r/${file.name}`} />
<meta name='twitter:player:width' content='720' />
<meta name='twitter:player:height' content='480' />
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} />
{file.thumbnail && (
<>
<meta name='twitter:image' content={`${host}/r/${file.thumbnail.name}`} />
<meta property='og:image' content={`${host}/r/${file.thumbnail.name}`} />
</>
)}
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:video' content={`${host}/r/${file.name}`} />
<meta property='og:video:url' content={`${host}/r/${file.name}`} />
<meta property='og:video:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`/r/${file.name}`} />
<meta property='og:video' content={`/r/${file.name}`} />
<meta property='og:video:url' content={`/r/${file.name}`} />
<meta property='og:video:secure_url' content={`/r/${file.name}`} />
<meta property='og:video:type' content={file.mimetype} />
<meta property='og:video:width' content='720' />
<meta property='og:video:height' content='480' />
@@ -140,22 +127,19 @@ export default function EmbeddedFile({
{file.mimetype.startsWith('audio') && (
<>
<meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`/r/${file.name}`} />
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} />
<meta name='twitter:player:width' content='720' />
<meta name='twitter:player:height' content='480' />
<meta property='og:type' content='music.song' />
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:audio' content={`${host}/r/${file.name}`} />
<meta property='og:audio:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`/r/${file.name}`} />
<meta property='og:audio' content={`/r/${file.name}`} />
<meta property='og:audio:secure_url' content={`/r/${file.name}`} />
<meta property='og:audio:type' content={file.mimetype} />
</>
)}
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`/r/${file.name}`} />
)}
<title>{file.name}</title>
</Head>
@@ -218,27 +202,9 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
where: {
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
},
include: {
thumbnail: true,
},
});
let host = context.req.headers.host;
if (!file) return { notFound: true };
const proto = context.req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(context.req.headers['cf-visitor'] as string).scheme === 'https' ||
proto === 'https' ||
zconfig.core.return_https
)
host = `https://${host}`;
else host = `http://${host}`;
} catch (e) {
if (proto === 'https' || zconfig.core.return_https) host = `https://${host}`;
else host = `http://${host}`;
}
const user = await prisma.user.findFirst({
where: {
id: file.userId,
@@ -265,11 +231,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
if (file.password) file.password = true;
return {
props: {
file,
image: file,
user,
pass,
prismRender: true,
host,
},
};
}
@@ -287,7 +252,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
props: {
file,
user,
host,
},
};
}
@@ -300,7 +264,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
file,
user,
pass: file.password ? true : false,
host,
compress,
},
};
-30
View File
@@ -1,30 +0,0 @@
import config from 'lib/config';
import { readdir, rm } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
async function main() {
const temp = config.core.temp_directory;
if (!existsSync(temp)) {
console.log('Temp directory does not exist, exiting..');
process.exit(0);
}
const files = (await readdir(temp)).filter(
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_')
);
if (files.length === 0) {
console.log('No partial files found, exiting..');
process.exit(0);
} else {
for (const file of files) {
console.log(`Deleting ${file}`);
await rm(join(temp, file));
}
console.log('Done!');
process.exit(0);
}
}
main();
-3
View File
@@ -47,9 +47,6 @@ async function main() {
},
},
});
await prisma.$disconnect();
console.log(`Deleted ${count} files from the database.`);
for (let i = 0; i !== toDelete.length; ++i) {
-2
View File
@@ -52,8 +52,6 @@ async function main() {
await datasource.save(file, await readFile(join(directory, file)));
}
console.log(`Finished copying files to ${config.datasource.type} storage.`);
process.exit(0);
}
main();
+1 -7
View File
@@ -1,7 +1,6 @@
import { PrismaClient } from '@prisma/client';
import config from 'lib/config';
import { migrations } from 'server/util';
import { inspect } from 'util';
async function main() {
const extras = (process.argv[2] ?? '').split(',');
@@ -14,7 +13,6 @@ async function main() {
const select = {
username: true,
administrator: true,
superAdmin: true,
id: true,
};
for (let i = 0; i !== extras.length; ++i) {
@@ -32,11 +30,7 @@ async function main() {
select,
});
await prisma.$disconnect();
console.log(inspect(users, false, 4, true));
process.exit(0);
console.log(JSON.stringify(users, null, 2));
}
main();
+2 -38
View File
@@ -8,42 +8,13 @@ async function main() {
await migrations();
const prisma = new PrismaClient();
let notFound = false;
const files = await prisma.file.findMany({
...(process.argv.includes('--force-update')
? undefined
: {
where: {
size: 0,
},
}),
select: {
id: true,
name: true,
size: true,
},
});
const files = await prisma.file.findMany();
console.log(`The script will attempt to query the size of ${files.length} files.`);
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
if (!(await datasource.get(file.name))) {
if (process.argv.includes('--force-delete')) {
console.log(`File ${file.name} does not exist. Deleting...`);
await prisma.file.delete({
where: {
id: file.id,
},
});
continue;
} else {
notFound ? null : (notFound = true);
continue;
}
}
const size = await datasource.size(file.name);
if (size === 0) {
console.log(`File ${file.name} has a size of 0 bytes. Ignoring...`);
@@ -60,14 +31,7 @@ async function main() {
}
}
await prisma.$disconnect();
notFound
? console.log(
'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.'
)
: console.log('Done.');
console.log('Done.');
process.exit(0);
}
-4
View File
@@ -66,15 +66,11 @@ async function main() {
data,
});
await prisma.$disconnect();
if (args[1] === 'password') {
parsed = '***';
}
console.log(`Updated user ${user.id} with ${args[1]} = ${parsed}`);
process.exit(0);
}
main();
-1
View File
@@ -7,7 +7,6 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
done();
async function preFile(this: FastifyReply, file: File) {
if (file.favorite) return false;
if (file.expiresAt && file.expiresAt < new Date()) {
await this.server.datasource.delete(file.name);
await this.server.prisma.file.delete({ where: { id: file.id } });
+1 -62
View File
@@ -1,12 +1,11 @@
import config from 'lib/config';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import { getStats } from 'server/util';
import { version } from '../../package.json';
import { getStats } from 'server/util';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { createReadStream, existsSync, readFileSync } from 'fs';
import { Worker } from 'worker_threads';
import dbFileDecorator from './decorators/dbFile';
import notFound from './decorators/notFound';
import postFileDecorator from './decorators/postFile';
@@ -101,18 +100,6 @@ async function start() {
return reply.type('image/x-icon').send(favicon);
});
if (config.features.robots_txt) {
server.get('/robots.txt', async (_, reply) => {
return reply.type('text/plain').send(`User-Agent: *
Disallow: /r/
Disallow: /api/
Disallow: /view/
Disallow: ${config.uploader.route}
Disallow: ${config.urls.route}
`);
});
}
// makes sure to handle both in one route as you cant have two handlers with the same route
if (config.urls.route === '/' && config.uploader.route === '/') {
server.route({
@@ -184,12 +171,9 @@ Disallow: ${config.urls.route}
await clearInvites.bind(server)();
await stats.bind(server)();
if (config.features.thumbnails) await thumbs.bind(server)();
setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000);
if (config.features.thumbnails)
setInterval(() => thumbs.bind(server)(), config.core.thumbnails_interval * 1000);
}
async function stats(this: FastifyInstance) {
@@ -221,51 +205,6 @@ async function clearInvites(this: FastifyInstance) {
logger.child('invites').debug(`deleted ${count} used invites`);
}
async function thumbs(this: FastifyInstance) {
const videoFiles = await this.prisma.file.findMany({
where: {
mimetype: {
startsWith: 'video/',
},
thumbnail: null,
},
include: {
thumbnail: true,
},
});
// avoids reaching prisma connection limit
const MAX_THUMB_THREADS = 4;
// make all the files fit into 4 arrays
const chunks = [];
for (let i = 0; i !== MAX_THUMB_THREADS; ++i) {
chunks.push([]);
for (let j = i; j < videoFiles.length; j += MAX_THUMB_THREADS) {
chunks[i].push(videoFiles[j]);
}
}
logger.child('thumbnail').debug(`starting ${chunks.length} thumbnail threads`);
for (let i = 0; i !== chunks.length; ++i) {
const chunk = chunks[i];
if (chunk.length === 0) continue;
logger.child('thumbnail').debug(`starting thumbnail generation for ${chunk.length} videos`);
new Worker('./dist/worker/thumbnail.js', {
workerData: {
videos: chunk,
config,
datasource,
},
});
}
}
function genFastifyOpts(): FastifyServerOptions {
const opts = {};
-1
View File
@@ -61,7 +61,6 @@ export async function migrations() {
logger.error(
`Unable to connect to database \`${process.env.DATABASE_URL}\`, check your database connection`
);
logger.debug(error);
} else {
logger.error('Failed to migrate database... exiting...');
logger.error(error);
-128
View File
@@ -1,128 +0,0 @@
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
import { spawn } from 'child_process';
import ffmpeg from 'ffmpeg-static';
import { createWriteStream } from 'fs';
import { rm } from 'fs/promises';
import type { Config } from 'lib/config/Config';
import Logger from 'lib/logger';
import { randomChars } from 'lib/util';
import { join } from 'path';
import { isMainThread, workerData } from 'worker_threads';
import datasource from 'lib/datasource';
const { videos, config } = workerData as {
videos: (File & {
thumbnail: Thumbnail;
})[];
config: Config;
};
const logger = Logger.get('worker::thumbnail').child(randomChars(4));
logger.debug(`thumbnail generation for ${videos.length} videos`);
if (isMainThread) {
logger.error('worker is not a thread');
process.exit(1);
}
async function loadThumbnail(path) {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
const data: Buffer = await new Promise((resolve, reject) => {
const buffers = [];
child.stdout.on('data', (chunk) => {
buffers.push(chunk);
});
child.once('error', reject);
child.once('close', (code) => {
if (code !== 0) {
reject(new Error(`child exited with code ${code}`));
} else {
const buffer = Buffer.allocUnsafe(buffers.reduce((acc, val) => acc + val.length, 0));
let offset = 0;
for (let i = 0; i !== buffers.length; ++i) {
const chunk = buffers[i];
chunk.copy(buffer, offset);
offset += chunk.length;
}
resolve(buffer);
}
});
});
return data;
}
async function loadFileTmp(file: File) {
const stream = await datasource.get(file.name);
// pipe to tmp file
const tmpFile = join(config.core.temp_directory, `zipline_thumb_${file.id}_${file.id}.tmp`);
const fileWriteStream = createWriteStream(tmpFile);
await new Promise((resolve, reject) => {
stream.pipe(fileWriteStream);
stream.once('error', reject);
stream.once('end', resolve);
});
return tmpFile;
}
async function start() {
const prisma = new PrismaClient();
for (let i = 0; i !== videos.length; ++i) {
const file = videos[i];
if (!file.mimetype.startsWith('video/')) {
logger.info('file is not a video');
process.exit(0);
}
if (file.thumbnail) {
logger.info('thumbnail already exists');
process.exit(0);
}
const tmpFile = await loadFileTmp(file);
logger.debug(`loaded file to tmp: ${tmpFile}`);
const thumbnail = await loadThumbnail(tmpFile);
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes mjpeg`);
const { thumbnail: thumb } = await prisma.file.update({
where: {
id: file.id,
},
data: {
thumbnail: {
create: {
name: `.thumb-${file.id}.jpg`,
},
},
},
select: {
thumbnail: true,
},
});
await datasource.save(thumb.name, thumbnail);
logger.info(`thumbnail saved - ${thumb.name}`);
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);
logger.debug(`removing tmp file: ${tmpFile}`);
await rm(tmpFile);
}
await prisma.$disconnect();
process.exit(0);
}
start();
+29 -22
View File
@@ -10,11 +10,11 @@ import { IncompleteFile, InvisibleFile } from '@prisma/client';
import { removeGPSData } from 'lib/utils/exif';
import { sendUpload } from 'lib/discord';
import { createInvisImage, hashPassword } from 'lib/util';
import formatFileName from 'lib/format';
export type UploadWorkerData = {
user: UserExtended;
file: {
id: number;
filename: string;
mimetype: string;
identifier: string;
@@ -24,6 +24,7 @@ export type UploadWorkerData = {
response: {
expiresAt?: Date;
format: NameFormat;
imageCompressionPercent?: number;
fileMaxViews?: number;
};
headers: Record<string, string>;
@@ -45,12 +46,7 @@ if (!file.lastchunk) {
if (!config.chunks.enabled) {
logger.error('chunks are not enabled, worker should not have been started');
if (file.id) {
prisma.file.delete({ where: { id: file.id } }).then(() => {
logger.debug('deleted a file entry due to anomalous worker start');
process.exit(1);
});
} else process.exit(1);
process.exit(1);
}
start();
@@ -79,12 +75,21 @@ async function start() {
},
});
const compressionUsed = response.imageCompressionPercent && file.mimetype.startsWith('image/');
const ext = file.filename.split('.').length === 1 ? '' : file.filename.split('.').pop();
const fileName = await formatFileName(response.format, file.filename);
let fd;
if (config.datasource.type === 'local') {
fd = await open(join(config.datasource.local.directory, file.filename), 'w');
fd = await open(
join(
process.cwd(),
config.datasource.local.directory,
`${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`
),
'w'
);
} else {
fd = new Uint8Array(file.totalBytes);
}
@@ -121,7 +126,10 @@ async function start() {
await fd.close();
} else {
logger.debug('writing file to datasource');
await datasource.save(file.filename, Buffer.from(fd as Uint8Array));
await datasource.save(
`${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
Buffer.from(fd as Uint8Array)
);
}
const final = await prisma.incompleteFile.update({
@@ -135,7 +143,7 @@ async function start() {
logger.debug('done writing file');
await runFileComplete(file.id, ext, final);
await runFileComplete(fileName, ext, compressionUsed, final);
logger.debug('done running worker');
process.exit(0);
@@ -145,11 +153,6 @@ async function setResponse(incompleteFile: IncompleteFile, code: number, message
incompleteFile.data['code'] = code;
incompleteFile.data['message'] = message;
if (code !== 200) {
await datasource.delete(file.filename);
await prisma.file.delete({ where: { id: file.id } });
}
return prisma.incompleteFile.update({
where: {
id: incompleteFile.id,
@@ -160,7 +163,12 @@ async function setResponse(incompleteFile: IncompleteFile, code: number, message
});
}
async function runFileComplete(id: number, ext: string, incompleteFile: IncompleteFile) {
async function runFileComplete(
fileName: string,
ext: string,
compressionUsed: boolean,
incompleteFile: IncompleteFile
) {
if (config.uploader.disabled_extensions.includes(ext))
return setResponse(incompleteFile, 403, 'disabled extension');
@@ -171,11 +179,11 @@ async function runFileComplete(id: number, ext: string, incompleteFile: Incomple
let invis: InvisibleFile;
const fFile = await prisma.file.update({
where: {
id,
},
const fFile = await prisma.file.create({
data: {
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
mimetype: file.mimetype,
userId: user.id,
embed: !!headers.embed,
password,
expiresAt: response.expiresAt,
@@ -185,8 +193,7 @@ async function runFileComplete(id: number, ext: string, incompleteFile: Incomple
},
});
if (typeof headers.zws !== 'undefined' && (headers.zws as string).toLowerCase().match('true'))
invis = await createInvisImage(config.uploader.length, fFile.id);
if (headers.zws) invis = await createInvisImage(config.uploader.length, fFile.id);
logger.info(`User ${user.username} (${user.id}) uploaded ${fFile.name} (${fFile.id}) (chunked)`);
let domain;
-10
View File
@@ -19,11 +19,6 @@ export default defineConfig([
outDir: 'dist/worker',
...opts,
},
{
entryPoints: ['src/worker/thumbnail.ts'],
outDir: 'dist/worker',
...opts,
},
// scripts
{
entryPoints: ['src/scripts/import-dir.ts'],
@@ -55,9 +50,4 @@ export default defineConfig([
outDir: 'dist/scripts',
...opts,
},
{
entryPoints: ['src/scripts/clear-temp.ts'],
outDir: 'dist/scripts',
...opts,
},
]);
+4 -82
View File
@@ -1142,18 +1142,6 @@ __metadata:
languageName: node
linkType: hard
"@derhuerst/http-basic@npm:^8.2.0":
version: 8.2.4
resolution: "@derhuerst/http-basic@npm:8.2.4"
dependencies:
caseless: ^0.12.0
concat-stream: ^2.0.0
http-response-object: ^3.0.1
parse-cache-control: ^1.0.1
checksum: dfb2f30c23fb907988d1c34318fa74c54dcd3c3ba6b4b0e64cdb584d03303ad212dd3b3874328a9367d7282a232976acbd33a20bb9c7a6ea20752e879459253b
languageName: node
linkType: hard
"@emotion/babel-plugin@npm:^11.10.6":
version: 11.10.6
resolution: "@emotion/babel-plugin@npm:11.10.6"
@@ -2731,13 +2719,6 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^10.0.3":
version: 10.17.60
resolution: "@types/node@npm:10.17.60"
checksum: 2cdb3a77d071ba8513e5e8306fa64bf50e3c3302390feeaeff1fd325dd25c8441369715dfc8e3701011a72fed5958c7dfa94eb9239a81b3c286caa4d97db6eef
languageName: node
linkType: hard
"@types/node@npm:^17.0.45":
version: 17.0.45
resolution: "@types/node@npm:17.0.45"
@@ -3841,16 +3822,9 @@ __metadata:
linkType: hard
"caniuse-lite@npm:^1.0.30001406":
version: 1.0.30001494
resolution: "caniuse-lite@npm:1.0.30001494"
checksum: 770b742ebba6076da72e94f979ef609bbc855369d1b937c52227935d966b11c3b02baa6511fba04a804802b6eb22af0a2a4a82405963bbb769772530e6be7a8e
languageName: node
linkType: hard
"caseless@npm:^0.12.0":
version: 0.12.0
resolution: "caseless@npm:0.12.0"
checksum: b43bd4c440aa1e8ee6baefee8063b4850fd0d7b378f6aabc796c9ec8cb26d27fb30b46885350777d9bd079c5256c0e1329ad0dc7c2817e0bb466810ebb353751
version: 1.0.30001439
resolution: "caniuse-lite@npm:1.0.30001439"
checksum: 3912dd536c9735713ca85e47721988bbcefb881ddb4886b0b9923fa984247fd22cba032cf268e57d158af0e8a2ae2eae042ae01942a1d6d7849fa9fa5d62fb82
languageName: node
linkType: hard
@@ -4162,18 +4136,6 @@ __metadata:
languageName: node
linkType: hard
"concat-stream@npm:^2.0.0":
version: 2.0.0
resolution: "concat-stream@npm:2.0.0"
dependencies:
buffer-from: ^1.0.0
inherits: ^2.0.3
readable-stream: ^3.0.2
typedarray: ^0.0.6
checksum: d7f75d48f0ecd356c1545d87e22f57b488172811b1181d96021c7c4b14ab8855f5313280263dca44bb06e5222f274d047da3e290a38841ef87b59719bde967c7
languageName: node
linkType: hard
"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
version: 1.1.0
resolution: "console-control-strings@npm:1.1.0"
@@ -5663,18 +5625,6 @@ __metadata:
languageName: node
linkType: hard
"ffmpeg-static@npm:^5.1.0":
version: 5.1.0
resolution: "ffmpeg-static@npm:5.1.0"
dependencies:
"@derhuerst/http-basic": ^8.2.0
env-paths: ^2.2.0
https-proxy-agent: ^5.0.0
progress: ^2.0.3
checksum: 0e27d671a0be1f585ef03e48c2af7c2be14f4e61470ffa02e3b8919551243ee854028a898dfcd16cdf1e3c01916f3c5e9938f42cbc7e877d7dd80d566867db8b
languageName: node
linkType: hard
"file-entry-cache@npm:^6.0.1":
version: 6.0.1
resolution: "file-entry-cache@npm:6.0.1"
@@ -6385,15 +6335,6 @@ __metadata:
languageName: node
linkType: hard
"http-response-object@npm:^3.0.1":
version: 3.0.2
resolution: "http-response-object@npm:3.0.2"
dependencies:
"@types/node": ^10.0.3
checksum: 6cbdcb4ce7b27c9158a131b772c903ed54add2ba831e29cc165e91c3969fa6f8105ddf924aac5b954b534ad15a1ae697b693331b2be5281ee24d79aae20c3264
languageName: node
linkType: hard
"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.0":
version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1"
@@ -8933,13 +8874,6 @@ __metadata:
languageName: node
linkType: hard
"parse-cache-control@npm:^1.0.1":
version: 1.0.1
resolution: "parse-cache-control@npm:1.0.1"
checksum: 5a70868792124eb07c2dd07a78fcb824102e972e908254e9e59ce59a4796c51705ff28196d2b20d3b7353d14e9f98e65ed0e4eda9be072cc99b5297dc0466fee
languageName: node
linkType: hard
"parse-json@npm:^4.0.0":
version: 4.0.0
resolution: "parse-json@npm:4.0.0"
@@ -9367,7 +9301,7 @@ __metadata:
languageName: node
linkType: hard
"progress@npm:2.0.3, progress@npm:^2.0.3":
"progress@npm:2.0.3":
version: 2.0.3
resolution: "progress@npm:2.0.3"
checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
@@ -9802,17 +9736,6 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:^3.0.2":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: ^2.0.3
string_decoder: ^1.1.1
util-deprecate: ^1.0.1
checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
languageName: node
linkType: hard
"readable-stream@npm:^4.0.0":
version: 4.2.0
resolution: "readable-stream@npm:4.2.0"
@@ -11979,7 +11902,6 @@ __metadata:
fastify: ^4.15.0
fastify-plugin: ^4.5.0
fflate: ^0.7.4
ffmpeg-static: ^5.1.0
find-my-way: ^7.6.0
katex: ^0.16.4
mantine-datatable: ^2.2.6