mirror of
https://github.com/diced/zipline.git
synced 2026-07-04 03:14:50 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adb984b2db | |||
| 3be9f1521e | |||
| 5ded128263 | |||
| 5d971a9fef | |||
| 2c86abbf4e | |||
| eedeb89c7d | |||
| bf40fa9cd2 | |||
| bc58c1b56e | |||
| c57a6e1700 | |||
| 8649a489d8 | |||
| 40f29907c7 | |||
| 34005ece43 | |||
| 8e6fc1e8a3 | |||
| 065f44b145 | |||
| e5a07f568d |
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
"{t}"
|
||||
</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 "{folders.data.find((f) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</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 : (
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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']);
|
||||
// }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user