Compare commits

..

15 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
Jayvin Hernandez 5ded128263 fix: user uuid (#355)
* fix: user uuid is used instead of user id for its uniqueness

* fix: use cuid instead & exclude from parser

* fix: apply new foreign key constraints to existing data

* fix: migration partly done

* not-fix: General form of migration achieved, still broken

* fix: migrate and use db's uuid function for existing users

* fix: Proper not nulling!

* fix: #354

* fix: migration & use uuid instead

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
Co-authored-by: diced <pranaco2@gmail.com>
2023-04-04 20:07:41 -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
dicedtomato eedeb89c7d feat: offloaded chunked uploads (#356)
* feat: offloaded chunked uploads

* fix: use temp_directory instead of tmpdir()

* feat: CHUNKS_ENABLED config
2023-04-03 22:42:27 -07:00
Jayvin Hernandez bf40fa9cd2 feat: many things (#351)
* remove source from final image

* move check state to ClearStorage

* use inspect for fancy colors

* newlines are now possible! yay!

* Catch user's leave if uploading

* feat?: Temp directory can be specified by the user.
Default is /tmp/zipline (or os equivalent)

* fix: ignore onDash config, use only ?compress query

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-31 22:25:00 -07:00
diced bc58c1b56e fix: milestone again again again again again again 2023-03-31 22:12:13 -07:00
diced c57a6e1700 fix: milestone again again again again again 2023-03-31 22:07:17 -07:00
diced 8649a489d8 fix: milestone again again again again 2023-03-31 22:05:59 -07:00
diced 40f29907c7 fix: milestone again again again 2023-03-31 21:55:26 -07:00
diced 34005ece43 fix: milestone again again 2023-03-31 21:53:30 -07:00
diced 8e6fc1e8a3 fix: milestone again 2023-03-31 21:49:50 -07:00
diced 065f44b145 fix: milestone 2023-03-31 21:41:19 -07:00
diced e5a07f568d fix: update milestone action 2023-03-27 16:48:56 -07:00
40 changed files with 1474 additions and 196 deletions
+12 -5
View File
@@ -1,24 +1,31 @@
name: 'Issue/PR Milestones'
on:
pull_request:
pull_request_target:
types: [opened, reopened]
issues:
types: [opened, reopened]
permissions:
issues: write
checks: write
contents: read
pull-requests: write
jobs:
set:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/github-script@v3
- uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const milestone = 2
github.issues.update({
const milestone = 3
github.rest.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
milestone
})
})
+2 -1
View File
@@ -69,7 +69,8 @@ COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/
# Copy Startup Script
COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
-- CreateTable
CREATE TABLE "IncompleteFile" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "ProcessingStatus" NOT NULL,
"chunks" INTEGER NOT NULL,
"chunksComplete" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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;
@@ -0,0 +1,53 @@
/*
Warnings:
- A unique constraint covering the columns `[uuid]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- PRISMA GENERATED BELOW
-- -- DropForeignKey
-- ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_fkey";
--
-- -- AlterTable
-- ALTER TABLE "OAuth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
--
-- -- AlterTable
-- ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
--
-- -- CreateIndex
-- CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
--
-- -- AddForeignKey
-- ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
-- User made changes below
-- Rename old foreign key
ALTER TABLE "OAuth" RENAME CONSTRAINT "OAuth_userId_fkey" TO "OAuth_userId_old_fkey";
-- Rename old column
ALTER TABLE "OAuth" RENAME COLUMN "userId" TO "userId_old";
-- Add new column
ALTER TABLE "OAuth" ADD COLUMN "userId" UUID;
-- Add user uuid
ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
-- Update table "OAuth" with uuid
UPDATE "OAuth" SET "userId" = "User"."uuid" FROM "User" WHERE "OAuth"."userId_old" = "User"."id";
-- Alter table "OAuth" to make "userId" required
ALTER TABLE "OAuth" ALTER COLUMN "userId" SET NOT NULL;
-- Create index
CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
-- Add new foreign key
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
-- Drop old foreign key
ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_old_fkey";
-- Drop old column
ALTER TABLE "OAuth" DROP COLUMN "userId_old";
+36 -4
View File
@@ -9,6 +9,7 @@ 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?
@@ -25,6 +26,7 @@ model User {
urls Url[]
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
}
model Folder {
@@ -58,8 +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?
tags Tag[]
}
model Tag {
id String @id @default(cuid())
name String
color String
files File[]
}
model InvisibleFile {
@@ -111,8 +123,8 @@ model Invite {
model OAuth {
id Int @id @default(autoincrement())
provider OauthProviders
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
userId String
username String
oauthId String?
token String
@@ -126,3 +138,23 @@ enum OauthProviders {
GITHUB
GOOGLE
}
model IncompleteFile {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
status ProcessingStatus
chunks Int
chunksComplete Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
data Json
}
enum ProcessingStatus {
PENDING
PROCESSING
COMPLETE
}
+147 -31
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,
@@ -62,9 +71,14 @@ export default function FileModal({
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, {
@@ -209,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} />
@@ -224,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}
@@ -269,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 && (
@@ -282,32 +424,6 @@ export default function FileModal({
</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}>
<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 : (
+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>
);
}
@@ -0,0 +1,118 @@
import { Button, Modal, Title, Tooltip } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { DataTable } from 'mantine-datatable';
import { useEffect, useState } from 'react';
export type PendingFiles = {
id: number;
createdAt: string;
status: string;
chunks: number;
chunksComplete: number;
userId: number;
data: {
file: {
filename: string;
mimetype: string;
lastchunk: boolean;
identifier: string;
totalBytes: number;
};
code?: number;
message?: string;
};
};
export default function PendingFilesModal({ open, onClose }) {
const [incFiles, setIncFiles] = useState<PendingFiles[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<PendingFiles[]>([]);
async function updateIncFiles() {
setLoading(true);
const files = await useFetch('/api/user/pending');
setIncFiles(files);
setLoading(false);
}
async function deleteIncFiles() {
await useFetch('/api/user/pending', 'DELETE', {
id: selectedFiles.map((file) => file.id),
});
updateIncFiles();
setSelectedFiles([]);
}
useEffect(() => {
updateIncFiles();
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (open) updateIncFiles();
}, 5000);
return () => clearInterval(interval);
}, [open]);
return (
<Modal title={<Title>Pending Files</Title>} size='auto' opened={open} onClose={onClose}>
<MutedText size='xs'>Refreshing every 5 seconds...</MutedText>
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
minHeight={200}
records={incFiles ?? []}
columns={[
{ accessor: 'id', title: 'ID' },
{ accessor: 'createdAt', render: (file) => new Date(file.createdAt).toLocaleString() },
{ accessor: 'status', render: (file) => file.status.toLowerCase() },
{
accessor: 'progress',
title: 'Progress',
render: (file) => `${file.chunksComplete}/${file.chunks} chunks`,
},
{
accessor: 'message',
render: (file) =>
file.data.code === 200 ? (
<AnchorNext href={file.data.message} target='_blank'>
view file
</AnchorNext>
) : (
file.data.message
),
},
]}
fetching={loading}
loaderBackgroundBlur={5}
loaderVariant='dots'
onSelectedRecordsChange={setSelectedFiles}
selectedRecords={selectedFiles}
/>
{selectedFiles.length ? (
<Tooltip label='Clearing pending files will still leave the final file on the server.'>
<Button
variant='filled'
my='md'
color='red'
onClick={deleteIncFiles}
leftIcon={<IconTrash size='1rem' />}
fullWidth
>
Clear {selectedFiles.length} pending file{selectedFiles.length > 1 ? 's' : ''}
</Button>
</Tooltip>
) : null}
</Modal>
);
}
+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>
</>
);
}
+21 -2
View File
@@ -1,17 +1,22 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
import { IconFileUpload } from '@tabler/icons-react';
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
import { IconFileUpload, IconPhotoUp, IconTags } from '@tabler/icons-react';
import File from 'components/File';
import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal';
import TagsModal from 'components/pages/Files/TagsModal';
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
const [pendingOpen, setPendingOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
useEffect(() => {
(async () => {
const { count } = await useFetch('/api/user/paged?count=true&filter=media&favorite=true');
@@ -21,11 +26,25 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
return (
<>
<PendingFilesModal open={pendingOpen} onClose={() => setPendingOpen(false)} />
<TagsModal open={tagsOpen} onClose={() => setTagsOpen(false)} />
<Group mb='md'>
<Title>Files</Title>
<ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'>
<IconFileUpload size='1rem' />
</ActionIcon>
<Tooltip label='View pending uploads'>
<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
+7 -2
View File
@@ -3,8 +3,10 @@ import { closeAllModals, openConfirmModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFiles, IconFilesOff } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useState } from 'react';
export default function ClearStorage({ open, setOpen, check, setCheck }) {
export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({
id: 'clear-uploads',
@@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
setCheck(() => false);
}}
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
>
<Checkbox
+3 -5
View File
@@ -4,7 +4,7 @@ import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react';
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
const [secret, setSecret] = useState('');
const [qrCode, setQrCode] = useState('');
const [disabled, setDisabled] = useState(false);
@@ -52,8 +52,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />,
});
setTotpEnabled(false);
setUser((user) => ({ ...user, totpSecret: null }));
onClose();
}
@@ -83,8 +82,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />,
});
setTotpEnabled(true);
setUser((user) => ({ ...user, totpSecret: secret }));
onClose();
}
+4 -4
View File
@@ -89,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 [checked, setCheck] = useState(false);
const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => {
@@ -355,7 +354,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
useEffect(() => {
getExports();
interval.start();
}, [totpEnabled]);
setTotpEnabled(() => !!user.totpSecret);
}, [user]);
return (
<>
@@ -450,7 +450,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
opened={totpOpen}
onClose={() => setTotpOpen(false)}
deleteTotp={totpEnabled}
setTotpEnabled={setTotpEnabled}
setUser={setUser}
/>
</Box>
)}
@@ -626,7 +626,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
</>
);
}
+34 -26
View File
@@ -13,8 +13,11 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
export default function File({ chunks: chunks_config }) {
const router = useRouter();
const clipboard = useClipboard();
const modals = useModals();
const user = useRecoilValue(userSelector);
@@ -25,6 +28,24 @@ export default function File({ chunks: chunks_config }) {
const [options, setOpened, OptionsModal] = useUploadOptions();
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 = (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';
}
}
};
useEffect(() => {
const listener = (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
@@ -41,8 +62,14 @@ export default function File({ chunks: chunks_config }) {
};
document.addEventListener('paste', listener);
return () => document.removeEventListener('paste', listener);
}, []);
window.addEventListener('beforeunload', beforeUnload);
router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
for (let i = 0; i !== toChunkFiles.length; ++i) {
@@ -71,18 +98,6 @@ export default function File({ chunks: chunks_config }) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// if last chunk send notif that it will take a while
if (j === chunks.length - 1) {
updateNotification({
id: 'upload-chunked',
title: 'Finalizing partial upload',
message: 'This may take a while...',
icon: <IconFileTime size='1rem' />,
color: 'yellow',
autoClose: false,
});
}
const body = new FormData();
body.append('file', chunks[j].blob);
@@ -109,25 +124,18 @@ export default function File({ chunks: chunks_config }) {
if (j === chunks.length - 1) {
updateNotification({
id: 'upload-chunked',
title: 'Upload Successful',
message: '',
title: 'Finalizing partial upload',
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',
icon: <IconFileUpload size='1rem' />,
autoClose: true,
});
showFilesModal(clipboard, modals, json.files);
invalidateFiles();
setFiles([]);
setProgress(100);
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;
+2
View File
@@ -1,5 +1,6 @@
export interface ConfigCore {
return_https: boolean;
temp_directory: string;
secret: string;
host: string;
port: number;
@@ -135,6 +136,7 @@ export interface ConfigOAuth {
export interface ConfigChunks {
max_size: number;
chunks_size: number;
enabled: boolean;
}
export interface ConfigMfa {
+2
View File
@@ -57,6 +57,7 @@ export default function readConfig() {
const maps = [
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
map('CORE_TEMP_DIRECTORY', 'path', 'core.temp_directory'),
map('CORE_SECRET', 'string', 'core.secret'),
map('CORE_HOST', 'string', 'core.host'),
map('CORE_PORT', 'number', 'core.port'),
@@ -157,6 +158,7 @@ export default function readConfig() {
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'),
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),
+5
View File
@@ -3,6 +3,8 @@ import type { Config } from './Config';
import { inspect } from 'util';
import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes';
import { tmpdir } from 'os';
import { join } from 'path';
const discord_content = s
.object({
@@ -27,6 +29,7 @@ const discord_content = s
const validator = s.object({
core: s.object({
return_https: s.boolean.default(false),
temp_directory: s.string.default(join(tmpdir(), 'zipline')),
secret: s.string.lengthGreaterThanOrEqual(8),
host: s.string.default('0.0.0.0'),
port: s.number.default(3000),
@@ -198,10 +201,12 @@ const validator = s.object({
.object({
max_size: s.number.default(humanToBytes('90MB')),
chunks_size: s.number.default(humanToBytes('20MB')),
enabled: s.boolean.default(true),
})
.default({
max_size: humanToBytes('90MB'),
chunks_size: humanToBytes('20MB'),
enabled: true,
}),
mfa: s
.object({
+3 -3
View File
@@ -135,7 +135,7 @@ export const withOAuth =
} else throw e;
}
res.setUserCookie(user.id);
res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
return res.redirect('/');
@@ -153,7 +153,7 @@ export const withOAuth =
},
});
res.setUserCookie(user.id);
res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
@@ -203,7 +203,7 @@ export const withOAuth =
logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`);
logger.info(`Created user ${nuser.username} via oauth(${provider})`);
res.setUserCookie(nuser.id);
res.setUserCookie(nuser.uuid);
logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
+6 -6
View File
@@ -54,7 +54,7 @@ export type NextApiRes = NextApiResponse &
NextApiResExtraObj & {
json: (json: Record<string, unknown>, status?: number) => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
setUserCookie: (id: number) => void;
setUserCookie: (id: string) => void;
};
export type ZiplineApiConfig = {
@@ -184,7 +184,7 @@ export const withZipline =
const user = await prisma.user.findFirst({
where: {
id: Number(userId),
uuid: userId,
},
include: {
oauth: true,
@@ -202,22 +202,22 @@ export const withZipline =
}
};
res.setCookie = (name: string, value: unknown, options: CookieSerializeOptions = {}) => {
res.setCookie = (name: string, value: string, options: CookieSerializeOptions = {}) => {
if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000;
}
const signed = sign64(String(value), config.core.secret);
const signed = sign64(value, config.core.secret);
Logger.get('api').debug(`headers(${JSON.stringify(req.headers)}): cookie(${name}, ${value})`);
res.setHeader('Set-Cookie', serialize(name, signed, options));
};
res.setUserCookie = (id: number) => {
res.setUserCookie = (id: string) => {
req.cleanCookie('user');
res.setCookie('user', String(id), {
res.setCookie('user', id, {
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
+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>;
}
+1 -2
View File
@@ -3,7 +3,6 @@ import { createWriteStream } from 'fs';
import { ExifTool, Tags } from 'exiftool-vendored';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import { tmpdir } from 'os';
import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
@@ -34,7 +33,7 @@ export async function readMetadata(filePath: string): Promise<Tags> {
export async function removeGPSData(image: File): Promise<void> {
const exiftool = new ExifTool({ cleanupChildProcs: false });
const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.name}`);
const file = join(config.core.temp_directory, `zipline-exif-remove-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to remove GPS data: ${file}`);
const stream = await datasource.get(image.name);
+5 -2
View File
@@ -11,7 +11,10 @@ export type ParseValue = {
export function parseString(str: string, value: ParseValue) {
if (!str) return null;
str = str.replace(/\{link\}/gi, value.link).replace(/\{raw_link\}/gi, value.raw_link);
str = str
.replace(/\{link\}/gi, value.link)
.replace(/\{raw_link\}/gi, value.raw_link)
.replace(/\\n/g, '\n');
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
let matches: RegExpMatchArray;
@@ -24,7 +27,7 @@ export function parseString(str: string, value: ParseValue) {
continue;
}
if (['password', 'avatar'].includes(matches.groups.prop)) {
if (['password', 'avatar', 'uuid'].includes(matches.groups.prop)) {
str = replaceCharsFromString(str, '{unknown_property}', matches.index, re.lastIndex);
re.lastIndex = matches.index;
continue;
+1 -1
View File
@@ -56,7 +56,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!success) return res.badRequest('Invalid code', { totp: true });
}
res.setUserCookie(user.id);
res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) logged in`);
return res.json({ success: true });
+1 -2
View File
@@ -6,7 +6,6 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { readMetadata } from 'lib/utils/exif';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path';
const logger = Logger.get('exif');
@@ -41,7 +40,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(data);
} else {
const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.name}`);
const file = join(config.core.temp_directory, `zipline-exif-read-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to view metadata: ${file}`);
const stream = await datasource.get(image.name);
+24 -84
View File
@@ -1,5 +1,5 @@
import { InvisibleFile } from '@prisma/client';
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
import { writeFile } from 'fs/promises';
import zconfig from 'lib/config';
import datasource from 'lib/datasource';
import { sendUpload } from 'lib/discord';
@@ -12,9 +12,9 @@ import { createInvisImage, hashPassword } from 'lib/util';
import { parseExpiry } from 'lib/utils/client';
import { removeGPSData } from 'lib/utils/exif';
import multer from 'multer';
import { tmpdir } from 'os';
import { join } from 'path';
import sharp from 'sharp';
import { Worker } from 'worker_threads';
const uploader = multer();
const logger = Logger.get('upload');
@@ -79,7 +79,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
// handle partial uploads before ratelimits
if (req.headers['content-range']) {
if (req.headers['content-range'] && zconfig.chunks.enabled) {
// parses content-range header (bytes start-end/total)
const [start, end, total] = req.headers['content-range']
.replace('bytes ', '')
@@ -104,94 +104,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
})}`
);
const tempFile = join(tmpdir(), `zipline_partial_${identifier}_${start}_${end}`);
const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
logger.debug(`writing partial to disk ${tempFile}`);
await writeFile(tempFile, req.files[0].buffer);
if (lastchunk) {
const partials = await readdir(tmpdir()).then((files) =>
files.filter((x) => x.startsWith(`zipline_partial_${identifier}`))
);
const readChunks = partials.map((x) => {
const [, , , start, end] = x.split('_');
return { start: Number(start), end: Number(end), filename: x };
});
// combine chunks
const chunks = new Uint8Array(total);
for (let i = 0; i !== readChunks.length; ++i) {
const chunkData = readChunks[i];
const buffer = await readFile(join(tmpdir(), chunkData.filename));
await unlink(join(tmpdir(), readChunks[i].filename));
chunks.set(buffer, chunkData.start);
}
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
if (zconfig.uploader.disabled_extensions.includes(ext))
return res.error('disabled extension recieved: ' + ext);
const fileName = await formatFileName(format, filename);
let password = null;
if (req.headers.password) {
password = await hashPassword(req.headers.password as string);
}
const compressionUsed = imageCompressionPercent && mimetype.startsWith('image/');
let invis: InvisibleFile;
const file = await prisma.file.create({
data: {
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
mimetype,
userId: user.id,
embed: !!req.headers.embed,
password,
expiresAt: expiry,
maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? filename ?? null : null,
new Worker('./dist/worker/upload.js', {
workerData: {
user,
file: {
filename,
mimetype,
identifier,
lastchunk,
totalBytes: total,
},
response: {
expiresAt: expiry,
format,
imageCompressionPercent,
fileMaxViews,
},
headers: req.headers,
},
});
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, file.id);
await datasource.save(file.name, Buffer.from(chunks));
logger.info(`User ${user.username} (${user.id}) uploaded ${file.name} (${file.id}) (chunked)`);
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 + '/'}${
invis ? invis.invis : encodeURI(file.name)
}`;
response.files.push(responseUrl);
if (zconfig.discord?.upload) {
await sendUpload(user, file, `${domain}/r/${invis ? invis.invis : file.name}`, responseUrl);
}
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
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);
return res.json({
pending: true,
});
}
return res.json({
+4 -5
View File
@@ -5,7 +5,6 @@ import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path';
const logger = Logger.get('user::export');
@@ -22,7 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const zip = new Zip();
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(tmpdir(), export_name);
const path = join(config.core.temp_directory, export_name);
logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path);
@@ -121,18 +120,18 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
const stream = createReadStream(join(tmpdir(), export_name));
const stream = createReadStream(join(config.core.temp_directory, export_name));
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
stream.pipe(res);
} else {
const files = await readdir(tmpdir());
const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = [];
for (let i = 0; i !== exp.length; ++i) {
const name = exp[i];
const stats = await stat(join(tmpdir(), name));
const stats = await stat(join(config.core.temp_directory, name));
if (Number(exp[i].split('_')[2]) !== user.id) continue;
exports.push({ name, size: stats.size });
+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,
});
+40
View File
@@ -0,0 +1,40 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.method === 'DELETE') {
const fileIds = req.body.id as number[];
const existingFiles = await prisma.incompleteFile.findMany({
where: {
id: {
in: fileIds,
},
userId: user.id,
},
});
const incFiles = await prisma.incompleteFile.deleteMany({
where: {
id: {
in: existingFiles.map((x) => x.id),
},
},
});
return res.json(incFiles);
} else {
const files = await prisma.incompleteFile.findMany({
where: {
userId: user.id,
},
});
return res.json(files);
}
}
export default withZipline(handler, {
methods: ['GET', 'DELETE'],
user: true,
});
+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,
});
+3 -2
View File
@@ -12,7 +12,8 @@ export default function OauthError({ error, provider }) {
useEffect(() => {
const interval = setInterval(() => {
setRemaining((remaining) => remaining - 1);
if (remaining > 0) setRemaining((remaining) => remaining - 1);
else clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
@@ -43,7 +44,7 @@ export default function OauthError({ error, provider }) {
</Title>
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>{error}</MutedText>
<MutedText>
Redirecting to login in {remaining} second{remaining === 1 ? 's' : ''}
Redirecting to login in {remaining} second{remaining !== 1 ? 's' : ''}
</MutedText>
<Button component={Link} href='/dashboard'>
Head to the Dashboard
+1 -6
View File
@@ -1,7 +1,6 @@
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import type { File } from '@prisma/client';
import AnchorNext from 'components/AnchorNext';
import config from 'lib/config';
import exts from 'lib/exts';
import prisma from 'lib/prisma';
import { parseString } from 'lib/utils/parser';
@@ -17,18 +16,15 @@ export default function EmbeddedFile({
user,
pass,
prismRender,
onDash,
compress,
}: {
file: File & { imageProps?: HTMLImageElement };
user: UserExtended;
pass: boolean;
prismRender: boolean;
onDash: boolean;
compress?: boolean;
}) {
const dataURL = (route: string) =>
`${route}/${encodeURI(file.name)}?compress=${compress == null ? onDash : compress}`;
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
const router = useRouter();
const [opened, setOpened] = useState(pass);
@@ -268,7 +264,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
file,
user,
pass: file.password ? true : false,
onDash: config.core.compression.on_dashboard,
compress,
},
};
+2 -1
View File
@@ -1,3 +1,4 @@
import config from 'lib/config';
import { inspect } from 'util';
console.log(JSON.stringify(config, null, 2));
console.log(inspect(config, { depth: Infinity, colors: true }));
+2
View File
@@ -7,6 +7,8 @@ function postUrlDecorator(fastify: FastifyInstance, _, done) {
done();
async function postUrl(this: FastifyReply, url: Url) {
if (!url) return true;
const nUrl = await this.server.prisma.url.update({
where: {
id: url.id,
+23 -2
View File
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { mkdir, readdir } from 'fs/promises';
import type { Config } from 'lib/config/Config';
async function configPlugin(fastify: FastifyInstance, config: Config) {
@@ -16,7 +17,9 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
.error(
'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.'
)
.error('It is recomended to use a secret that is alphanumeric and randomized.')
.error(
'It is recomended to use a secret that is alphanumeric and randomized. If you include special characters, surround the secret with quotes.'
)
.error('A way you can generate this is through a password manager you may have.');
process.exit(1);
@@ -26,6 +29,24 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
await mkdir(config.datasource.local.directory, { recursive: true });
}
if (!existsSync(config.core.temp_directory)) {
await mkdir(config.core.temp_directory, { recursive: true });
} else {
const files = await readdir(config.core.temp_directory);
if (
files.filter((x: string) => x.startsWith('zipline_partial_') || x.startsWith('zipline-exif-read-'))
.length > 0
)
fastify.logger
.error("Found temporary files in Zipline's temp directory.")
.error('This can happen if Zipline crashes or is stopped while chunking a file.')
.error(
'If you are sure that no files are currently being processed, you can delete the files in the temp directory.'
)
.error('The temp directory is located at: ' + config.core.temp_directory)
.error('If you are unsure, you can safely ignore this message.');
}
return;
}
+225
View File
@@ -0,0 +1,225 @@
import { readdir, readFile, open, rm } from 'fs/promises';
import type { NameFormat } from 'lib/format';
import Logger from 'lib/logger';
import type { UserExtended } from 'middleware/withZipline';
import { isMainThread, workerData } from 'worker_threads';
import prisma from 'lib/prisma';
import { join } from 'path';
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: {
filename: string;
mimetype: string;
identifier: string;
lastchunk: boolean;
totalBytes: number;
};
response: {
expiresAt?: Date;
format: NameFormat;
imageCompressionPercent?: number;
fileMaxViews?: number;
};
headers: Record<string, string>;
};
const { user, file, response, headers } = workerData as UploadWorkerData;
const logger = Logger.get('worker::upload').child(file?.identifier ?? 'unknown-ident');
if (isMainThread) {
logger.error('worker is not a thread');
process.exit(1);
}
if (!file.lastchunk) {
logger.error('lastchunk is false, worker should not have been started');
process.exit(1);
}
if (!config.chunks.enabled) {
logger.error('chunks are not enabled, worker should not have been started');
process.exit(1);
}
start();
async function start() {
logger.debug('starting worker');
const partials = await readdir(config.core.temp_directory).then((files) =>
files.filter((x) => x.startsWith(`zipline_partial_${file.identifier}`))
);
const readChunks = partials.map((x) => {
const [, , , start, end] = x.split('_');
return { start: Number(start), end: Number(end), filename: x };
});
const incompleteFile = await prisma.incompleteFile.create({
data: {
data: {
file,
},
chunks: readChunks.length,
chunksComplete: 0,
status: 'PENDING',
userId: user.id,
},
});
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(
process.cwd(),
config.datasource.local.directory,
`${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`
),
'w'
);
} else {
fd = new Uint8Array(file.totalBytes);
}
for (let i = 0; i !== readChunks.length; ++i) {
const chunk = readChunks[i];
const buffer = await readFile(join(config.core.temp_directory, chunk.filename));
if (config.datasource.type === 'local') {
const { bytesWritten } = await fd.write(buffer, 0, buffer.length, chunk.start);
logger.child('fd').debug(`wrote ${bytesWritten} bytes to file`);
} else {
fd.set(buffer, chunk.start);
logger.child('bytes').debug(`wrote ${buffer.length} bytes to array`);
}
await rm(join(config.core.temp_directory, chunk.filename));
await prisma.incompleteFile.update({
where: {
id: incompleteFile.id,
},
data: {
chunksComplete: {
increment: 1,
},
status: 'PROCESSING',
},
});
}
if (config.datasource.type === 'local') {
await fd.close();
} else {
logger.debug('writing file to datasource');
await datasource.save(
`${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
Buffer.from(fd as Uint8Array)
);
}
const final = await prisma.incompleteFile.update({
where: {
id: incompleteFile.id,
},
data: {
status: 'COMPLETE',
},
});
logger.debug('done writing file');
await runFileComplete(fileName, ext, compressionUsed, final);
logger.debug('done running worker');
process.exit(0);
}
async function setResponse(incompleteFile: IncompleteFile, code: number, message: string) {
incompleteFile.data['code'] = code;
incompleteFile.data['message'] = message;
return prisma.incompleteFile.update({
where: {
id: incompleteFile.id,
},
data: {
data: incompleteFile.data,
},
});
}
async function runFileComplete(
fileName: string,
ext: string,
compressionUsed: boolean,
incompleteFile: IncompleteFile
) {
if (config.uploader.disabled_extensions.includes(ext))
return setResponse(incompleteFile, 403, 'disabled extension');
let password = null;
if (headers.password) {
password = await hashPassword(headers.password as string);
}
let invis: InvisibleFile;
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,
maxViews: response.fileMaxViews,
originalName: headers['original-name'] ? file.filename ?? null : null,
size: file.totalBytes,
},
});
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;
if (headers['override-domain']) {
domain = `${config.core.return_https ? 'https' : 'http'}://${headers['override-domain']}`;
} else if (user.domains.length) {
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
} else {
domain = `${config.core.return_https ? 'https' : 'http'}://${headers.host}`;
}
const responseUrl = `${domain}${config.uploader.route === '/' ? '/' : config.uploader.route + '/'}${
invis ? invis.invis : encodeURI(fFile.name)
}`;
if (config.discord?.upload) {
await sendUpload(user, fFile, `${domain}/r/${invis ? invis.invis : fFile.name}`, responseUrl);
}
if (config.exif.enabled && config.exif.remove_gps && fFile.mimetype.startsWith('image/')) {
try {
await removeGPSData(fFile);
} catch (e) {
logger.error(`Failed to remove GPS data from ${fFile.name} (${fFile.id}) - ${e.message}`);
}
}
await setResponse(incompleteFile, 200, responseUrl);
}
+6
View File
@@ -13,6 +13,12 @@ export default defineConfig([
entryPoints: ['src/server/index.ts'],
...opts,
},
// workers
{
entryPoints: ['src/worker/upload.ts'],
outDir: 'dist/worker',
...opts,
},
// scripts
{
entryPoints: ['src/scripts/import-dir.ts'],