Merge branch 'trunk' into feature/file-size

This commit is contained in:
dicedtomato 2023-03-04 04:22:59 +00:00 committed by GitHub
commit 38e30b2525
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1909 additions and 1156 deletions

View file

@ -1,5 +1,5 @@
# Use the Prisma binaries image as the first stage
FROM ghcr.io/diced/prisma-binaries:4.8.x as prisma
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
# Use Alpine Linux as the second stage
FROM node:18-alpine3.16 as base

View file

@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.0-rc3",
"version": "3.7.0-rc4",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@ -14,81 +14,80 @@
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist",
"lint": "next lint",
"docker:run": "docker-compose up -d",
"docker:up": "docker-compose up",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build",
"docker:run-dev": "docker-compose --file docker-compose.dev.yml up",
"docker:up-dev": "docker-compose --file docker-compose.dev.yml up",
"docker:down-dev": "docker-compose --file docker-compose.dev.yml down",
"scripts:read-config": "node dist/scripts/read-config",
"scripts:import-dir": "node dist/scripts/import-dir",
"scripts:list-users": "node dist/scripts/list-users",
"scripts:set-user": "node dist/scripts/set-user"
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
"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"
},
"dependencies": {
"@dicedtomato/mantine-data-grid": "0.0.23",
"@emotion/react": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^5.9.2",
"@mantine/dropzone": "^5.9.2",
"@mantine/form": "^5.9.2",
"@mantine/hooks": "^5.9.2",
"@mantine/modals": "^5.9.2",
"@mantine/next": "^5.9.2",
"@mantine/notifications": "^5.9.2",
"@mantine/nprogress": "^5.9.2",
"@mantine/prism": "^5.9.2",
"@prisma/client": "^4.8.1",
"@prisma/internals": "^4.8.1",
"@prisma/migrate": "^4.8.1",
"@sapphire/shapeshift": "^3.7.1",
"@tanstack/react-query": "^4.19.1",
"argon2": "^0.30.2",
"colorette": "^2.0.19",
"@mantine/core": "^5.10.5",
"@mantine/dropzone": "^5.10.5",
"@mantine/form": "^5.10.5",
"@mantine/hooks": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@mantine/next": "^5.10.5",
"@mantine/notifications": "^5.10.5",
"@mantine/prism": "^5.10.5",
"@prisma/client": "^4.10.1",
"@prisma/internals": "^4.10.1",
"@prisma/migrate": "^4.10.1",
"@sapphire/shapeshift": "^3.8.1",
"@tanstack/react-query": "^4.24.10",
"argon2": "^0.30.3",
"cookie": "^0.5.0",
"dayjs": "^1.11.7",
"dotenv": "^16.0.3",
"dotenv-expand": "^9.0.0",
"exiftool-vendored": "^18.6.0",
"fastify": "^4.10.2",
"fastify-plugin": "^4.4.0",
"dotenv-expand": "^10.0.0",
"exiftool-vendored": "^21.2.0",
"fastify": "^4.13.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"find-my-way": "^7.3.1",
"find-my-way": "^7.5.0",
"katex": "^0.16.4",
"mantine-datatable": "^1.8.6",
"minio": "^7.0.32",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^13.0.6",
"next": "^13.2.1",
"otplib": "^12.0.1",
"prisma": "^4.8.1",
"prisma": "^4.10.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-markdown": "^8.0.4",
"recharts": "^2.3.2",
"react-markdown": "^8.0.5",
"recharts": "^2.4.3",
"recoil": "^0.7.6",
"remark-gfm": "^3.0.1",
"sharp": "^0.31.2"
"sharp": "^0.31.3"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/katex": "^0.14.0",
"@types/minio": "^7.0.15",
"@types/katex": "^0.16.0",
"@types/minio": "^7.0.16",
"@types/multer": "^1.4.7",
"@types/node": "^18.11.12",
"@types/node": "^18.14.2",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.0.26",
"@types/sharp": "^0.31.0",
"@types/react": "^18.0.28",
"@types/sharp": "^0.31.1",
"cross-env": "^7.0.3",
"eslint": "^8.29.0",
"eslint-config-next": "^13.0.6",
"eslint-config-prettier": "^8.5.0",
"eslint": "^8.35.0",
"eslint-config-next": "^13.2.1",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.1",
"tsup": "^6.5.0",
"typescript": "^4.9.4"
"prettier": "^2.8.4",
"tsup": "^6.6.3",
"typescript": "^4.9.5"
},
"repository": {
"type": "git",

View file

@ -0,0 +1,345 @@
import {
ActionIcon,
Group,
LoadingOverlay,
Modal,
Select,
SimpleGrid,
Stack,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { relativeTime } from 'lib/utils/client';
import { useState } from 'react';
import { FileMeta } from '.';
import {
CalendarIcon,
ClockIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
FileIcon,
FolderMinusIcon,
FolderPlusIcon,
HashIcon,
ImageIcon,
InfoIcon,
StarIcon,
} from '../icons';
import Type from '../Type';
export default function FileModal({
open,
setOpen,
file,
loading,
refresh,
reducedActions = false,
exifEnabled,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file: any;
loading: boolean;
refresh: () => void;
reducedActions?: boolean;
exifEnabled?: boolean;
}) {
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const folders = useFolders();
const [overrideRender, setOverrideRender] = useState(false);
const clipboard = useClipboard();
const handleDelete = async () => {
deleteFile.mutate(file.id, {
onSuccess: () => {
showNotification({
title: 'File Deleted',
message: '',
color: 'green',
icon: <DeleteIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
onSettled: () => {
setOpen(false);
},
});
};
const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
setOpen(false);
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: <CopyIcon />,
});
};
const handleFavorite = async () => {
favoriteFile.mutate(
{ id: file.id, favorite: !file.favorite },
{
onSuccess: () => {
showNotification({
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
},
onError: (res: any) => {
showNotification({
title: 'Failed to favorite file',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
},
}
);
};
const inFolder = file.folderId;
const removeFromFolder = async () => {
const res = await useFetch('/api/user/folders/' + file.folderId, 'DELETE', {
file: Number(file.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Removed from folder',
message: res.name,
color: 'green',
icon: <FolderMinusIcon />,
});
} else {
showNotification({
title: 'Failed to remove from folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const addToFolder = async (t) => {
const res = await useFetch('/api/user/folders/' + t, 'POST', {
file: Number(file.id),
});
refresh();
if (!res.error) {
showNotification({
title: 'Added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to add to folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
};
const createFolder = (t) => {
useFetch('/api/user/folders', 'POST', {
name: t,
add: [Number(file.id)],
}).then((res) => {
refresh();
if (!res.error) {
showNotification({
title: 'Created & added to folder',
message: res.name,
color: 'green',
icon: <FolderPlusIcon />,
});
} else {
showNotification({
title: 'Failed to create folder',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
}
});
return { value: t, label: t };
};
return (
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{file.name}</Title>} size='xl'>
<LoadingOverlay visible={loading} />
<Stack>
<Type
file={file}
src={`/r/${encodeURI(file.name)}`}
alt={file.name}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
disableMediaPreview={false}
overrideRender={overrideRender}
setOverrideRender={setOverrideRender}
/>
<SimpleGrid
my='md'
cols={3}
breakpoints={[
{ maxWidth: 600, cols: 1 },
{ maxWidth: 900, cols: 2 },
{ maxWidth: 1200, cols: 3 },
]}
>
<FileMeta Icon={FileIcon} title='Name' subtitle={file.name} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={file.mimetype} />
<FileMeta Icon={EyeIcon} title='Views' subtitle={file?.views?.toLocaleString()} />
{file.maxViews && (
<FileMeta
Icon={EyeIcon}
title='Max views'
subtitle={file?.maxViews?.toLocaleString()}
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
/>
)}
<FileMeta
Icon={CalendarIcon}
title='Uploaded'
subtitle={relativeTime(new Date(file.createdAt))}
tooltip={new Date(file?.createdAt).toLocaleString()}
/>
{file.expiresAt && !reducedActions && (
<FileMeta
Icon={ClockIcon}
title='Expires'
subtitle={relativeTime(new Date(file.expiresAt))}
tooltip={new Date(file.expiresAt).toLocaleString()}
/>
)}
<FileMeta Icon={HashIcon} title='ID' subtitle={file.id} />
</SimpleGrid>
</Stack>
<Group position='apart' my='md'>
<Group position='left'>
{exifEnabled && !reducedActions && (
<Tooltip label='View Metadata'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
>
<InfoIcon />
</ActionIcon>
</Tooltip>
)}
{reducedActions ? 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}>
<FolderMinusIcon />
</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 : (
<>
<Tooltip label='Delete file'>
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
<ActionIcon
color={file.favorite ? 'yellow' : 'gray'}
variant='filled'
onClick={handleFavorite}
>
<StarIcon />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label='Open in new tab'>
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
<ExternalLinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy URL'>
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
<CopyIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Download'>
<ActionIcon
color='blue'
variant='filled'
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Modal>
);
}

View file

@ -0,0 +1,90 @@
import { Card, Group, LoadingOverlay, Stack, Text, Tooltip } from '@mantine/core';
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
import { useFolders } from 'lib/queries/folders';
import { useState } from 'react';
import MutedText from '../MutedText';
import Type from '../Type';
import FileModal from './FileModal';
export function FileMeta({ Icon, title, subtitle, ...other }) {
return other.tooltip ? (
<Group>
<Icon size={24} />
<Tooltip label={other.tooltip}>
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Tooltip>
</Group>
) : (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({
image,
disableMediaPreview,
exifEnabled,
refreshImages,
reducedActions = false,
}) {
const [open, setOpen] = useState(false);
const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite();
const loading = deleteFile.isLoading || favoriteFile.isLoading;
const folders = useFolders();
const refresh = () => {
refreshImages();
folders.refetch();
};
return (
<>
<FileModal
open={open}
setOpen={setOpen}
file={image}
loading={loading}
refresh={refresh}
reducedActions={reducedActions}
exifEnabled={exifEnabled}
/>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<LoadingOverlay visible={loading} />
<Type
file={image}
sx={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
style={{
minHeight: 200,
maxHeight: 320,
fontSize: 70,
width: '100%',
cursor: 'pointer',
}}
src={`/r/${encodeURI(image.name)}`}
alt={image.name}
onClick={() => setOpen(true)}
disableMediaPreview={disableMediaPreview}
/>
</Card.Section>
</Card>
</>
);
}

View file

@ -4,8 +4,10 @@ import {
Box,
Burger,
Button,
Group,
Header,
Image,
Input,
MediaQuery,
Menu,
Navbar,
@ -18,7 +20,7 @@ import {
Tooltip,
useMantineTheme,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useClipboard, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
@ -214,13 +216,29 @@ export default function Layout({ children, props }) {
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
if (!navigator.clipboard)
showNotification({
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',
});
else
showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
@ -323,7 +341,16 @@ export default function Layout({ children, props }) {
</MediaQuery>
<Title ml='sm'>{title}</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Menu>
<Menu
styles={{
item: {
'@media (max-width: 768px)': {
padding: '1rem',
width: '80vw',
},
},
}}
>
<Menu.Target>
<Button
leftIcon={avatar ? <Image src={avatar} height={32} radius='md' /> : <SettingsIcon />}
@ -395,7 +422,7 @@ export default function Layout({ children, props }) {
</>
<Menu.Item closeMenuOnClick={false} icon={<PencilIcon />}>
<Select
size='xs'
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
data={Object.keys(themes).map((t) => ({
value: t,
label: friendlyThemeName[t],

View file

@ -1,16 +1,16 @@
import exts from 'lib/exts';
import {
Alert,
Box,
Button,
Card,
Center,
Container,
Group,
Image,
LoadingOverlay,
Text,
UnstyledButton,
} from '@mantine/core';
import { Prism } from '@mantine/prism';
import { useEffect, useState } from 'react';
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
import KaTeX from './render/KaTeX';
@ -27,6 +27,15 @@ function PlaceholderContent({ text, Icon }) {
}
function Placeholder({ text, Icon, ...props }) {
if (props.onClick)
return (
<UnstyledButton sx={{ height: 200 }} {...props}>
<Center sx={{ height: 200 }}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
</UnstyledButton>
);
return (
<Box sx={{ height: 200 }} {...props}>
<Center sx={{ height: 200 }}>
@ -47,10 +56,11 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
const [text, setText] = useState('');
const shouldRenderMarkdown = file.name.endsWith('.md');
const shouldRenderTex = file.name.endsWith('.tex');
const shouldRenderCode: boolean = Object.keys(exts).includes(file.name.split('.').pop());
const [loading, setLoading] = useState(type === 'text' && popup);
if (type === 'text' && popup) {
if ((type === 'text' || shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && popup) {
useEffect(() => {
(async () => {
const res = await fetch('/r/' + file.name);
@ -62,7 +72,7 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
}, []);
}
const renderRenderAlert = () => {
const renderAlert = () => {
return (
<Alert color='blue' variant='outline' sx={{ width: '100%' }}>
You are{props.overrideRender ? ' not ' : ' '}viewing a rendered version of the file
@ -78,39 +88,53 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
);
};
if ((shouldRenderMarkdown || shouldRenderTex) && !props.overrideRender && popup)
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
return (
<>
{renderRenderAlert()}
{renderAlert()}
<Card p='md' my='sm'>
{shouldRenderMarkdown && <Markdown code={text} />}
{shouldRenderTex && <KaTeX code={text} />}
{shouldRenderCode && !(shouldRenderTex || shouldRenderMarkdown) && (
<PrismCode code={text} ext={type} />
)}
</Card>
</>
);
if (media && disableMediaPreview) {
return <Placeholder Icon={FileIcon} text={`Click to view file (${name})`} {...props} />;
return <Placeholder Icon={FileIcon} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={FileIcon}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
return popup ? (
media ? (
{
video: <video width='100%' autoPlay controls {...props} />,
video: <video width='100%' autoPlay muted controls {...props} />,
image: (
<Image
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
{...props}
/>
),
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
audio: <audio autoPlay muted controls {...props} style={{ width: '100%' }} />,
text: (
<>
{loading ? (
<LoadingOverlay visible={loading} />
) : (
<>
{(shouldRenderMarkdown || shouldRenderTex) && renderRenderAlert()}
{(shouldRenderMarkdown || shouldRenderTex) && renderAlert()}
<PrismCode code={text} ext={file.name.split('.').pop()} {...props} />
</>
)}

View file

@ -1,42 +1,88 @@
import { DataGrid, dateFilterFn, stringFilterFn } from '@dicedtomato/mantine-data-grid';
import { Title, useMantineTheme, Box } from '@mantine/core';
import { ActionIcon, Box, Group, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon } from 'components/icons';
import FileModal from 'components/File/FileModal';
import { CopyIcon, CrossIcon, DeleteIcon, EnterIcon, FileIcon } from 'components/icons';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch';
import { useFiles, 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 { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
const user = useRecoilValue(userSelector);
const theme = useMantineTheme();
const images = useFiles();
const recent = useRecent('media');
const stats = useStats();
const clipboard = useClipboard();
const updateImages = () => {
images.refetch();
// pagination
const [, setNumPages] = useState(0);
const [page, setPage] = useState(1);
const [numFiles, setNumFiles] = useState(0);
useEffect(() => {
(async () => {
const { count } = await useFetch('/api/user/paged?count=true');
setNumPages(count);
const { count: filesCount } = await useFetch('/api/user/files?count=true');
setNumFiles(filesCount);
})();
}, [page]);
const files = usePaginatedFiles(page);
// sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'date',
direction: 'asc',
});
const [records, setRecords] = useState(files.data);
useEffect(() => {
setRecords(files.data);
}, [files.data]);
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);
const [selectedFile, setSelectedFile] = useState(null);
const updateFiles = () => {
files.refetch();
recent.refetch();
stats.refetch();
};
const deleteImage = async ({ original }) => {
const deleteFile = async (file) => {
const res = await useFetch('/api/user/files', 'DELETE', {
id: original.id,
id: file.id,
});
if (!res.error) {
updateImages();
updateFiles();
showNotification({
title: 'File Deleted',
message: `${original.name}`,
message: `${file.name}`,
color: 'green',
icon: <DeleteIcon />,
});
@ -50,28 +96,47 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
}
};
const copyImage = async ({ original }) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
showNotification({
title: 'Copied to clipboard',
message: (
<a
href={`${window.location.protocol}//${window.location.host}${original.url}`}
>{`${window.location.protocol}//${window.location.host}${original.url}`}</a>
),
icon: <CopyIcon />,
});
const copyFile = async (file) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
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: <CopyIcon />,
});
};
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
const viewFile = async (file) => {
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
};
return (
<div>
{selectedFile && (
<FileModal
open={open}
setOpen={setOpen}
file={selectedFile}
loading={files.isLoading}
refresh={() => files.refetch()}
reducedActions={false}
exifEnabled={exifEnabled}
/>
)}
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>
You have <b>{images.isSuccess ? images.data.length : '...'}</b> files
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
</MutedText>
<StatCards />
@ -83,74 +148,92 @@ export default function Dashboard({ disableMediaPreview, exifEnabled }) {
<MutedText size='md'>
View your gallery <Link href='/dashboard/files'>here</Link>.
</MutedText>
<DataGrid
data={images.data ?? []}
loading={images.isLoading}
withPagination={true}
withColumnResizing={false}
withColumnFilters={true}
noEllipsis={true}
withSorting={true}
highlightOnHover={true}
CopyIcon={CopyIcon}
DeleteIcon={DeleteIcon}
EnterIcon={EnterIcon}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
styles={{
dataCell: {
width: '100%',
},
td: {
':nth-of-child(1)': {
minWidth: 170,
},
':nth-of-child(2)': {
minWidth: 100,
},
},
th: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
':nth-of-child(1)': {
minWidth: 170,
padding: theme.spacing.lg,
borderTopLeftRadius: theme.radius.sm,
},
':nth-of-child(2)': {
minWidth: 100,
padding: theme.spacing.lg,
},
':nth-of-child(3)': {
padding: theme.spacing.lg,
},
':nth-of-child(4)': {
padding: theme.spacing.lg,
borderTopRightRadius: theme.radius.sm,
},
},
thead: {
backgroundColor: theme.colors.dark[6],
},
}}
empty={<></>}
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
columns={[
{ accessor: 'name', sortable: true },
{ accessor: 'mimetype', sortable: true },
{
accessorKey: 'name',
header: 'Name',
filterFn: stringFilterFn,
accessor: 'createdAt',
sortable: true,
render: (file) => new Date(file.createdAt).toLocaleString(),
},
{
accessorKey: 'mimetype',
header: 'Type',
filterFn: stringFilterFn,
},
{
accessorKey: 'createdAt',
header: 'Date',
filterFn: dateFilterFn,
accessor: 'actions',
textAlignment: 'right',
render: (file) => (
<Group spacing={4} position='right' noWrap>
<Tooltip label='More details'>
<ActionIcon
onClick={() => {
setSelectedFile(file);
setOpen(true);
}}
color='blue'
>
<FileIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Open file in new tab'>
<ActionIcon onClick={() => viewFile(file)} color='blue'>
<EnterIcon />
</ActionIcon>
</Tooltip>
<ActionIcon onClick={() => copyFile(file)} color='green'>
<CopyIcon />
</ActionIcon>
<ActionIcon onClick={() => deleteFile(file)} color='red'>
<DeleteIcon />
</ActionIcon>
</Group>
),
},
]}
records={records ?? []}
fetching={files.isLoading}
loaderBackgroundBlur={5}
loaderVariant='dots'
minHeight={620}
page={page}
onPageChange={setPage}
recordsPerPage={16}
totalRecords={numFiles}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
rowContextMenu={{
shadow: 'xl',
borderRadius: 'md',
items: (file) => [
{
key: 'view',
icon: <EnterIcon />,
title: `View ${file.name}`,
onClick: () => viewFile(file),
},
{
key: 'copy',
icon: <CopyIcon />,
title: `Copy ${file.name}`,
onClick: () => copyFile(file),
},
{
key: 'delete',
icon: <DeleteIcon />,
title: `Delete ${file.name}`,
onClick: () => deleteFile(file),
},
],
}}
onCellClick={({ record: file }) => {
setSelectedFile(file);
setOpen(true);
}}
/>
</Box>
</div>

View file

@ -1,4 +1,5 @@
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import File from 'components/File';
import { FileIcon } from 'components/icons';
import MutedText from 'components/MutedText';
@ -14,6 +15,8 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
const [numPages, setNumPages] = useState(Number(queryPage)); // just set it to the queryPage, since the req may have not loaded yet
const [page, setPage] = useState(Number(queryPage));
const isMobile = useMediaQuery('(max-width: 768px)');
const router = useRouter();
useEffect(() => {
@ -92,13 +95,15 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
paddingBottom: 3,
}}
>
<div></div>
<Pagination total={numPages} page={page} onChange={setPage} />
<Checkbox
label='Show non-media files'
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
{!isMobile && <div></div>}
<Pagination total={numPages} page={page} onChange={setPage} withEdges />
{!isMobile && (
<Checkbox
label='Show non-media files'
checked={checked}
onChange={(event) => setChecked(event.currentTarget.checked)}
/>
)}
</Box>
) : null}
</>

View file

@ -165,16 +165,23 @@ export default function Folders({ disableMediaPreview, exifEnabled }) {
aria-label='copy link'
onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
showNotification({
title: 'Copied folder link',
message: (
<>
Copied <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
</>
),
color: 'green',
icon: <CopyIcon />,
});
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 <Link href={`/folder/${folder.id}`}>folder link</Link> to clipboard
</>
),
color: 'green',
icon: <CopyIcon />,
});
}}
>
<LinkIcon />

View file

@ -158,11 +158,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: <CopyIcon />,
});
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: <CopyIcon />,
});
};
const updateInvites = async () => {

View file

@ -1,5 +1,6 @@
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useForm } from '@mantine/form';
import { CheckIcon, CrossIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react';
@ -10,6 +11,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
const [disabled, setDisabled] = useState(false);
const [code, setCode] = useState(undefined);
const [error, setError] = useState('');
const form = useForm();
useEffect(() => {
(async () => {
@ -114,28 +116,35 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
</>
)}
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
error={error}
/>
<Button
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<CheckIcon />}
onClick={deleteTotp ? disableTotp : verifyCode}
<form
onSubmit={form.onSubmit(() => {
deleteTotp ? disableTotp() : verifyCode();
})}
>
Verify{deleteTotp ? ' and Disable' : ''}
</Button>
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
data-autofocus
error={error}
/>
<Button
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<CheckIcon />}
onClick={deleteTotp ? disableTotp : verifyCode}
>
Verify{deleteTotp ? ' and Disable' : ''}
</Button>
</form>
</Modal>
);
}

View file

@ -16,7 +16,7 @@ import {
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { randomId, useInterval } from '@mantine/hooks';
import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
@ -40,7 +40,7 @@ import useFetch from 'hooks/useFetch';
import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes';
import { capitalize } from 'lib/utils/client';
import { useEffect, useReducer, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import ClearStorage from './ClearStorage';
import Flameshot from './Flameshot';
@ -394,7 +394,18 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
/>
<Group position='right' mt='md'>
<Button type='submit'>Save User</Button>
<Button
type='submit'
size='lg'
my='sm'
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Save
</Button>
</Group>
</form>
@ -407,7 +418,16 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
: 'You do not have two factor authentication enabled.'}
</MutedText>
<Button size='lg' my='sm' onClick={() => setTotpOpen(true)}>
<Button
size='lg'
my='sm'
onClick={() => setTotpOpen(true)}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
</Button>
@ -483,7 +503,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
</Button>
</Card>
<Group position='right' mt='md'>
<Group position='right' my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button
onClick={() => {
setFile(null);
@ -497,12 +517,12 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
</Group>
</Box>
<Box mb='md'>
<Box my='md'>
<Title>Manage Data</Title>
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
</Box>
<Group>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
Delete All Data
</Button>
@ -545,7 +565,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{user.administrator && (
<Box mt='md'>
<Title>Server</Title>
<Group>
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
Force Update Stats
</Button>
@ -558,10 +578,28 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Title my='md'>Uploaders</Title>
<Group>
<Button size='xl' onClick={() => setShareXOpen(true)} rightIcon={<ShareXIcon />}>
<Button
size='xl'
onClick={() => setShareXOpen(true)}
rightIcon={<ShareXIcon />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Generate ShareX Config
</Button>
<Button size='xl' onClick={() => setFlameshotOpen(true)} rightIcon={<FlameshotIcon />}>
<Button
size='xl'
onClick={() => setFlameshotOpen(true)}
rightIcon={<FlameshotIcon />}
sx={{
'@media screen and (max-width: 768px)': {
width: '100%',
},
}}
>
Generate Flameshot Script
</Button>
</Group>

View file

@ -27,11 +27,18 @@ export default function MetadataView({ fileId }) {
const copy = (value) => {
clipboard.copy(value);
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <CopyIcon />,
});
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: <CopyIcon />,
});
};
const searchValue = (value) => {

View file

@ -128,6 +128,12 @@ export default function File({ chunks: chunks_config }) {
setTimeout(() => setProgress(0), 1000);
clipboard.copy(json.files[0]);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
}
ready = true;

View file

@ -1,17 +1,24 @@
import { Button, Table, Title } from '@mantine/core';
import { ActionIcon, Box, Button, Group, Stack, Table, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { CopyIcon } from 'components/icons';
import { CopyIcon, LinkIcon } from 'components/icons';
import Link from 'components/Link';
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: <Link href={files[idx]}>{files[idx]}</Link>,
icon: <CopyIcon />,
});
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: <Link href={files[idx]}>{files[idx]}</Link>,
icon: <CopyIcon />,
});
};
modals.openModal({
@ -19,25 +26,27 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
size: 'auto',
children: (
<Table withBorder={false} withColumnBorders={false} highlightOnHover horizontalSpacing={'sm'}>
<tbody>
<Stack>
{files.map((file, idx) => (
<tr key={file}>
<td>
<Group key={idx} position='apart'>
<Group position='left'>
<Link href={file}>{file}</Link>
</td>
<td>
<Button.Group>
<Button variant='outline' onClick={() => copy(idx)}>
Copy
</Button>
<Button variant='outline' onClick={() => open(idx)}>
Open
</Button>
</Button.Group>
</td>
</tr>
</Group>
<Group position='right'>
<Tooltip label='Open link in a new tab'>
<ActionIcon onClick={() => open(idx)} variant='filled' color='primary'>
<LinkIcon />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy link to clipboard'>
<ActionIcon onClick={() => copy(idx)} variant='filled' color='primary'>
<CopyIcon />
</ActionIcon>
</Tooltip>
</Group>
</Group>
))}
</tbody>
</Stack>
</Table>
),
});

View file

@ -14,11 +14,18 @@ export default function URLCard({ url }: { url: URLResponse }) {
const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
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: <CopyIcon />,
});
};
const deleteURL = async (u) => {

View file

@ -250,10 +250,28 @@ export default function validate(config): Config {
}
}
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
show: true,
};
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
throw {
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
show: true,
};
}
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
if (e.show) {
Logger.get('config').error('Config is invalid, see below:').error(e.errors.join('\n'));
process.exit(1);
}
logger.debug(`config error: ${inspect(e, { depth: Infinity })}`);
e.stack = '';

View file

@ -1,4 +1,11 @@
import { blueBright, cyan, red, yellow } from 'colorette';
const COLORS = {
blueBright: (str: string) => `\x1b[34m${str}\x1b[0m`,
cyan: (str: string) => `\x1b[36m${str}\x1b[0m`,
red: (str: string) => `\x1b[31m${str}\x1b[0m`,
yellow: (str: string) => `\x1b[33m${str}\x1b[0m`,
gray: (str: string) => `\x1b[90m${str}\x1b[0m`,
};
import dayjs from 'dayjs';
export enum LoggerLevel {
@ -10,6 +17,10 @@ export enum LoggerLevel {
export default class Logger {
public name: string;
static filters(): string[] {
return (process.env.LOGGER_FILTERS ?? '').split(',').filter((x) => x !== '');
}
static get(klass: any) {
if (typeof klass !== 'function') if (typeof klass !== 'string') throw new Error('not string/function');
@ -26,13 +37,24 @@ export default class Logger {
return new Logger(`${this.name}::${name}`);
}
show(): boolean {
const filters = Logger.filters();
if (!filters.length) return true;
return filters.includes(this.name);
}
info(...args: any[]): this {
if (!this.show()) return this;
process.stdout.write(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
return this;
}
error(...args: any[]): this {
if (!this.show()) return this;
process.stdout.write(
this.formatMessage(LoggerLevel.ERROR, this.name, args.map((error) => error.stack ?? error).join(' '))
);
@ -41,7 +63,8 @@ export default class Logger {
}
debug(...args: any[]): this {
if (!process.env.DEBUG) return;
if (!process.env.DEBUG) return this;
if (!this.show()) return this;
process.stdout.write(this.formatMessage(LoggerLevel.DEBUG, this.name, args.join(' ')));
@ -50,17 +73,17 @@ export default class Logger {
formatMessage(level: LoggerLevel, name: string, message: string) {
const time = dayjs().format('YYYY-MM-DD hh:mm:ss,SSS A');
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}\n`;
return `${COLORS.gray(time)} ${this.formatLevel(level)} [${COLORS.blueBright(name)}] ${message}\n`;
}
formatLevel(level: LoggerLevel) {
switch (level) {
case LoggerLevel.INFO:
return cyan('info ');
return COLORS.cyan('info ');
case LoggerLevel.ERROR:
return red('error');
return COLORS.red('error');
case LoggerLevel.DEBUG:
return yellow('debug');
return COLORS.yellow('debug');
}
}
}

View file

@ -68,7 +68,6 @@ export async function removeGPSData(image: File): Promise<void> {
GPSLongitude: null,
GPSLongitudeRef: null,
GPSMapDatum: null,
GPSMeasureMode: null,
GPSPosition: null,
GPSProcessingMethod: null,
GPSSatellites: null,

View file

@ -191,8 +191,14 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
await removeGPSData(file);
response.removed_gps = true;
try {
await removeGPSData(file);
response.removed_gps = true;
} catch (e) {
logger.error(`Failed to remove GPS data from ${file.name} (${file.id}) - ${e.message}`);
response.removed_gps = false;
}
}
return res.json(response);
@ -330,8 +336,14 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}
if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) {
await removeGPSData(fileUpload);
response.removed_gps = true;
try {
await removeGPSData(fileUpload);
response.removed_gps = true;
} catch (e) {
logger.error(`Failed to remove GPS data from ${fileUpload.name} (${fileUpload.id}) - ${e.message}`);
response.removed_gps = false;
}
}
}

View file

@ -41,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info(`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id})`);
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
return res.json(file);
}
} else if (req.method === 'PATCH') {
@ -57,9 +59,20 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
});
delete image.password;
// @ts-ignore
if (file.password) file.password = true;
return res.json(image);
} else {
if (req.query.count) {
const count = await prisma.file.count({
where: {
userId: user.id,
favorite: !!req.query.favorite,
},
});
return res.json({ count });
}
let files: {
favorite: boolean;
createdAt: Date;

View file

@ -89,7 +89,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.query.files) {
for (let i = 0; i !== folder.files.length; ++i) {
const file = folder.files[i];
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
@ -123,7 +124,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.query.files) {
for (let i = 0; i !== folder.files.length; ++i) {
const file = folder.files[i];
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
@ -217,7 +219,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.query.files) {
for (let i = 0; i !== folder.files.length; ++i) {
const file = folder.files[i];
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
@ -232,7 +235,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.query.files) {
for (let i = 0; i !== folder.files.length; ++i) {
const file = folder.files[i];
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,

View file

@ -81,7 +81,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const folder = folders[i];
for (let j = 0; j !== folders[i].files.length; ++j) {
const file = folder.files[j];
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,

View file

@ -59,6 +59,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
views: number;
folderId: number;
size: number;
password: string | boolean;
}[] = await prisma.file.findMany({
where,
orderBy: {
@ -75,13 +76,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: true,
folderId: true,
size: true,
password: true,
},
skip: page ? (Number(page) - 1) * pageCount : undefined,
take: page ? pageCount : undefined,
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
const file = files[i];
if (file.password) file.password = true;
(file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name);
}
return res.json(files);

View file

@ -98,28 +98,24 @@ export default function Login({ title, user_registration, oauth_registration, oa
title={<Title order={3}>Two-Factor Authentication Required</Title>}
size='lg'
>
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
error={error}
/>
<form onSubmit={form.onSubmit(() => onSubmit(form.values))}>
<NumberInput
placeholder='2FA Code'
label='Verify'
size='xl'
hideControls
maxLength={6}
minLength={6}
value={code}
onChange={(e) => setCode(e)}
data-autofocus
error={error}
/>
<Button
disabled={disabled}
size='lg'
fullWidth
mt='md'
rightIcon={<CheckIcon />}
onClick={() => onSubmit(form.values)}
>
Verify &amp; Login
</Button>
<Button disabled={disabled} size='lg' fullWidth mt='md' rightIcon={<CheckIcon />} type='submit'>
Verify &amp; Login
</Button>
</form>
</Modal>
<Center sx={{ height: '100vh' }}>
<div>

View file

@ -35,8 +35,8 @@ export default function Folder({ title, folder }: Props) {
<title>{full_title}</title>
</Head>
<Container size='lg'>
<Title align='center' my='lg'>
Viewing folder: {folder.name}
<Title size={50} align='center' my='lg'>
{folder.name}
</Title>
<SimpleGrid
my='md'
@ -80,6 +80,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
id: true,
views: true,
createdAt: true,
password: true,
},
},
user: {
@ -101,6 +102,9 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
folder.files[j].name
);
// @ts-ignore
if (folder.files[j].password) folder.files[j].password = true;
(folder.files[j].createdAt as unknown) = folder.files[j].createdAt.toString();
}

View file

@ -197,7 +197,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
};
else if (prismRender && file.password) {
const pass = file.password ? true : false;
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
return {
props: {
image: file,
@ -214,7 +215,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
const data = await datasource.get(file.name);
if (!data) return { notFound: true };
delete file.password;
// @ts-ignore
if (file.password) file.password = true;
return {
props: {
@ -223,14 +225,14 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
},
};
}
// @ts-ignore
if (file.password) file.password = true;
const pass = file.password ? true : false;
delete file.password;
return {
props: {
file,
user,
pass,
pass: file.password ? true : false,
},
};
};

View file

@ -0,0 +1,64 @@
import { PrismaClient } from '@prisma/client';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import config from 'lib/config';
import datasource from 'lib/datasource';
import { guess } from 'lib/mimes';
import { migrations } from 'server/util';
async function main() {
process.env.DATABASE_URL = config.core.database_url;
await migrations();
const prisma = new PrismaClient();
const files = await prisma.file.findMany();
const toDelete = [];
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
const size = await datasource.size(file.name);
if (size === 0) {
toDelete.push(file.name);
}
}
if (toDelete.length === 0) {
console.log('No files to delete.');
process.exit(0);
}
process.stdout.write(`Found ${toDelete.length} files to delete. Continue? (y/N) `);
const answer: Buffer = await new Promise((resolve) => {
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (text) => {
resolve(text);
});
});
if (answer.toString().trim().toLowerCase() !== 'y') {
console.log('Aborting.');
process.exit(0);
}
const { count } = await prisma.file.deleteMany({
where: {
name: {
in: toDelete,
},
},
});
console.log(`Deleted ${count} files from the database.`);
for (let i = 0; i !== toDelete.length; ++i) {
await datasource.delete(toDelete[i]);
}
console.log(`Deleted ${toDelete.length} files from the storage.`);
process.exit(0);
}
main();

View file

@ -1,4 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import exts from 'lib/exts';
export default async function uploadsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
@ -16,7 +17,9 @@ export default async function uploadsRoute(this: FastifyInstance, req: FastifyRe
const failed = await reply.preFile(image);
if (failed) return reply.notFound();
if (image.password || image.embed || image.mimetype.startsWith('text/'))
const ext = image.name.split('.').pop();
if (image.password || image.embed || image.mimetype.startsWith('text/') || Object.keys(exts).includes(ext))
return reply.redirect(`/view/${image.name}`);
else return reply.dbFile(image);
}

View file

@ -27,12 +27,8 @@
},
"include": [
"next-env.d.ts",
"zip-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/**/*.ts",
"**/**/*.tsx",
"prisma/seed.ts"
],
"exclude": ["node_modules", "dist", ".yarn", ".next"]
}

View file

@ -34,4 +34,9 @@ export default defineConfig([
outDir: 'dist/scripts',
...opts,
},
{
entryPoints: ['src/scripts/clear-zero-byte.ts'],
outDir: 'dist/scripts',
...opts,
},
]);

1671
yarn.lock

File diff suppressed because it is too large Load diff