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