mirror of
https://github.com/diced/zipline.git
synced 2025-12-24 12:04:05 -08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725ce50608 | ||
|
|
78e884e97e | ||
|
|
cb123cb575 | ||
|
|
6f3081cb8e | ||
|
|
231f734fd5 | ||
|
|
fce7325a24 | ||
|
|
2bec45411f | ||
|
|
577195b578 | ||
|
|
a402227c4f | ||
|
|
a75b790654 | ||
|
|
f07cbeac52 | ||
|
|
dcfcce7803 | ||
|
|
659868181d | ||
|
|
d76e6444e0 | ||
|
|
0dbbf4840c | ||
|
|
1b6af9fc08 | ||
|
|
8e1541ea56 | ||
|
|
fd9908833a | ||
|
|
24f8300b2c | ||
|
|
8d510f5d90 | ||
|
|
6457680065 | ||
|
|
3175911105 | ||
|
|
00f26bdc75 | ||
|
|
9db95bb772 |
@@ -1,7 +1,7 @@
|
||||
# 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/swift make sure to comment out the other datasources
|
||||
# if using s3/supabase make sure to comment out the other datasources
|
||||
|
||||
CORE_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
@@ -25,15 +25,14 @@ DATASOURCE_S3_REGION=us-west-2
|
||||
DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
DATASOURCE_S3_USE_SSL=false
|
||||
|
||||
# or you can use swift
|
||||
DATASOURCE_TYPE=swift
|
||||
DATASOURCE_SWIFT_CONTAINER=container
|
||||
DATASOURCE_SWIFT_AUTH_ENDPOINT="https://something/v3"
|
||||
DATASOURCE_SWIFT_USERNAME=username
|
||||
DATASOURCE_SWIFT_PASSWORD=password
|
||||
DATASOURCE_SWIFT_PROJECT_ID=project_id
|
||||
DATASOURCE_SWIFT_DOMAIN_ID=domain_id
|
||||
# or supabase
|
||||
DATASOURCE_TYPE=supabase
|
||||
DATASOURCE_SUPABASE_KEY=xxx
|
||||
# remember: no leading slash
|
||||
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
|
||||
@@ -44,4 +43,4 @@ URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
RATELIMIT_ADMIN = 3
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/suggest.yml
vendored
12
.github/ISSUE_TEMPLATE/suggest.yml
vendored
@@ -1,12 +0,0 @@
|
||||
name: Suggestion
|
||||
description: Suggest a feature to be added
|
||||
title: 'Suggestion: '
|
||||
labels: ['suggestion']
|
||||
body:
|
||||
- type: textarea
|
||||
id: suggest
|
||||
attributes:
|
||||
label: Suggestion
|
||||
description: Be as descriptive as possible!
|
||||
placeholder: What do you want in Zipline?
|
||||
value: A suggestion
|
||||
16
README.md
16
README.md
@@ -31,6 +31,7 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- Code highlighting
|
||||
- Fully customizable Discord webhook notifications
|
||||
- OAuth2 registration (Discord and GitHub)
|
||||
- Two-Factor authentication with Google Authenticator, Authy, etc (totp services).
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
@@ -113,6 +114,17 @@ After navigating to Zipline, click on the top right corner where it says your us
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
<details>
|
||||
<summary>Wayland instructions</summary>
|
||||
|
||||
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
|
||||
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
|
||||
After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
|
||||
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
|
||||
|
||||
@@ -127,7 +139,7 @@ curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
Create an issue on GitHub and use the template, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
@@ -139,7 +151,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
|
||||
|
||||
Create an issue on GitHub, please include the following:
|
||||
|
||||
- Breif explanation of the feature in the title (very breif please)
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
@@ -49,13 +49,16 @@
|
||||
"dayjs": "^1.11.6",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^9.0.0",
|
||||
"exiftool-vendored": "^18.6.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.3.1",
|
||||
"minio": "^7.0.32",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^13.0.0",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^4.5.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -68,6 +71,7 @@
|
||||
"@types/minio": "^7.0.14",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.11.7",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/sharp": "^0.31.0",
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_urlId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Invite" DROP CONSTRAINT "Invite_createdById_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -20,6 +20,7 @@ model User {
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
images Image[]
|
||||
@@ -55,7 +56,7 @@ model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int @unique
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
image Image @relation(fields: [imageId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Url {
|
||||
@@ -66,15 +67,15 @@ model Url {
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Stats {
|
||||
@@ -89,7 +90,7 @@ model Invite {
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime?
|
||||
used Boolean @default(false)
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById Int
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
export default function File({ image, disableMediaPreview, exifEnabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
@@ -156,6 +156,11 @@ export default function File({ image, updateImages, disableMediaPreview }) {
|
||||
</Stack>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
{exifEnabled && (
|
||||
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
|
||||
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button onClick={handleCopy}>Copy URL</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { Box, Center, Group, Image, Text } from '@mantine/core';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AudioIcon, FileIcon, PlayIcon } from './icons';
|
||||
import { AudioIcon, FileIcon, ImageIcon, PlayIcon } from './icons';
|
||||
|
||||
function PlaceholderContent({ text, Icon }) {
|
||||
return (
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ text, Icon, ...props }) {
|
||||
if (props.disableResolve) props.src = null;
|
||||
|
||||
return (
|
||||
<Image
|
||||
height={200}
|
||||
withPlaceholder
|
||||
placeholder={
|
||||
<Group>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
<Box sx={{ height: 200 }} {...props}>
|
||||
<Center sx={{ height: 200 }}>
|
||||
<PlaceholderContent text={text} Icon={Icon} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +53,12 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
media ? (
|
||||
{
|
||||
video: <video width='100%' autoPlay controls {...props} />,
|
||||
image: <Image {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={FileIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <audio autoPlay controls {...props} style={{ width: '100%' }} />,
|
||||
text: (
|
||||
<Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>
|
||||
@@ -64,7 +72,12 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
) : media ? (
|
||||
{
|
||||
video: <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
|
||||
image: <Image {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={ImageIcon} text={'Image failed to load...'} />}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
audio: <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props} />,
|
||||
text: <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props} />,
|
||||
}[type]
|
||||
|
||||
5
src/components/icons/KeyIcon.tsx
Normal file
5
src/components/icons/KeyIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Key } from 'react-feather';
|
||||
|
||||
export default function KeyIcon({ ...props }) {
|
||||
return <Key size={15} {...props} />;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import DiscordIcon from './DiscordIcon';
|
||||
import GoogleIcon from './GoogleIcon';
|
||||
import EyeIcon from './EyeIcon';
|
||||
import RefreshIcon from './RefreshIcon';
|
||||
import KeyIcon from './KeyIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
@@ -68,4 +69,5 @@ export {
|
||||
GoogleIcon,
|
||||
EyeIcon,
|
||||
RefreshIcon,
|
||||
KeyIcon,
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Card as MantineCard, Center, Group, SimpleGrid, Skeleton, Title } from
|
||||
import { randomId } from '@mantine/hooks';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles, useRecent } from 'lib/queries/files';
|
||||
import { useRecent } from 'lib/queries/files';
|
||||
import { UploadCloud } from 'react-feather';
|
||||
|
||||
export default function RecentFiles({ disableMediaPreview }) {
|
||||
export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
||||
const recent = useRecent('media');
|
||||
|
||||
return (
|
||||
@@ -22,8 +22,8 @@ export default function RecentFiles({ disableMediaPreview }) {
|
||||
<File
|
||||
key={randomId()}
|
||||
image={image}
|
||||
updateImages={invalidateFiles}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useRecoilValue } from 'recoil';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
|
||||
export default function Dashboard({ disableMediaPreview }) {
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
@@ -35,14 +35,14 @@ export default function Dashboard({ disableMediaPreview }) {
|
||||
if (!res.error) {
|
||||
updateImages();
|
||||
showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
title: 'File Deleted',
|
||||
message: `${original.name}`,
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete image',
|
||||
title: 'Failed to Delete File',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
@@ -54,7 +54,11 @@ export default function Dashboard({ disableMediaPreview }) {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
message: (
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}${original.url}`}
|
||||
>{`${window.location.protocol}//${window.location.host}${original.url}`}</a>
|
||||
),
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
@@ -72,7 +76,7 @@ export default function Dashboard({ disableMediaPreview }) {
|
||||
|
||||
<StatCards />
|
||||
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} />
|
||||
<RecentFiles disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
|
||||
<Box my='sm'>
|
||||
<Title>Files</Title>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { Box, Button, Center, Checkbox, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import { FileIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function FilePagation({ disableMediaPreview }) {
|
||||
export default function FilePagation({ disableMediaPreview, exifEnabled }) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const pages = usePaginatedFiles(!checked ? { filter: 'media' } : {});
|
||||
@@ -13,7 +13,7 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
|
||||
if (pages.isSuccess && pages.data.length === 0) {
|
||||
return (
|
||||
<Center>
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
<div>
|
||||
<FileIcon size={48} />
|
||||
@@ -23,6 +23,14 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
<MutedText size='md'>Upload some files and they will show up here.</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
<Box my='sm' hidden={checked}>
|
||||
<MutedText size='md'>
|
||||
There might be some non-media files, would you like to show them?
|
||||
<Button mx='sm' compact type='button' onClick={() => setChecked(true)}>
|
||||
Show
|
||||
</Button>
|
||||
</MutedText>
|
||||
</Box>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -34,11 +42,7 @@ export default function FilePagation({ disableMediaPreview }) {
|
||||
? pages.data.length
|
||||
? pages.data[page - 1 ?? 0].map((image) => (
|
||||
<div key={image.id}>
|
||||
<File
|
||||
image={image}
|
||||
updateImages={() => pages.refetch()}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
<File image={image} disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
</div>
|
||||
))
|
||||
: null
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
|
||||
export default function Files({ disableMediaPreview }) {
|
||||
export default function Files({ disableMediaPreview, exifEnabled }) {
|
||||
const pages = usePaginatedFiles({ filter: 'media' });
|
||||
const favoritePages = usePaginatedFiles({ favorite: 'media' });
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
@@ -23,7 +23,7 @@ export default function Files({ disableMediaPreview }) {
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref legacyBehavior>
|
||||
<Link href='/dashboard/upload/file' passHref legacyBehavior>
|
||||
<ActionIcon component='a' variant='filled' color='primary'>
|
||||
<PlusIcon />
|
||||
</ActionIcon>
|
||||
@@ -47,8 +47,8 @@ export default function Files({ disableMediaPreview }) {
|
||||
<div key={image.id}>
|
||||
<File
|
||||
image={image}
|
||||
updateImages={() => updatePages(true)}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -74,7 +74,7 @@ export default function Files({ disableMediaPreview }) {
|
||||
</Accordion>
|
||||
) : null}
|
||||
|
||||
<FilePagation disableMediaPreview={disableMediaPreview} />
|
||||
<FilePagation disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
141
src/components/pages/Manage/TotpModal.tsx
Normal file
141
src/components/pages/Manage/TotpModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Button, Center, Image, Modal, NumberInput, Text, Title } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CheckIcon, CrossIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
const [secret, setSecret] = useState('');
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [code, setCode] = useState(undefined);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (opened && !deleteTotp) {
|
||||
const data = await useFetch('/api/user/mfa/totp');
|
||||
if (!data.data_url) {
|
||||
onClose();
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: "Can't generate code as you are already using MFA",
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
} else {
|
||||
setSecret(data.secret);
|
||||
setQrCode(data.data_url);
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [opened]);
|
||||
|
||||
const disableTotp = async () => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'DELETE', {
|
||||
code: str,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully disabled MFA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
setTotpEnabled(false);
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
setDisabled(true);
|
||||
const str = code.toString();
|
||||
if (str.length !== 6) {
|
||||
return setError('Code must be 6 digits');
|
||||
}
|
||||
|
||||
const resp = await useFetch('/api/user/mfa/totp', 'POST', {
|
||||
secret,
|
||||
code: str,
|
||||
register: true,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
setError(resp.error);
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Success',
|
||||
message: 'Successfully enabled MFA',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
setTotpEnabled(true);
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
setDisabled(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>Two-Factor Authentication</Title>}
|
||||
size='lg'
|
||||
>
|
||||
{deleteTotp ? (
|
||||
<Text mb='md'>Verify your code to disable Two-Factor Authentication</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text mb='md'>
|
||||
Scan the QR Code below in <b>Authy</b>, <b>Google Authenticator</b>, or any other supported
|
||||
client.
|
||||
</Text>
|
||||
<Center>
|
||||
<Image height={180} width={180} src={qrCode} alt='QR Code' withPlaceholder />
|
||||
</Center>
|
||||
<Text my='sm'>QR Code not working? Try manually entering the code into your app: {secret}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
placeholder='2FA Code'
|
||||
label='Verify'
|
||||
size='xl'
|
||||
hideControls
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e)}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
fullWidth
|
||||
mt='md'
|
||||
rightIcon={<CheckIcon />}
|
||||
onClick={deleteTotp ? disableTotp : verifyCode}
|
||||
>
|
||||
Verify{deleteTotp ? ' and Disable' : ''}
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import Flameshot from './Flameshot';
|
||||
import ShareX from './ShareX';
|
||||
import { TotpModal } from './TotpModal';
|
||||
|
||||
function ExportDataTooltip({ children }) {
|
||||
return (
|
||||
@@ -56,7 +57,7 @@ function ExportDataTooltip({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) {
|
||||
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers, totp_enabled }) {
|
||||
const oauth_providers = JSON.parse(raw_oauth_providers);
|
||||
const icons = {
|
||||
Discord: DiscordIcon,
|
||||
@@ -71,11 +72,13 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
const [user, setUser] = useRecoilState(userSelector);
|
||||
const modals = useModals();
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [shareXOpen, setShareXOpen] = useState(false);
|
||||
const [flameshotOpen, setFlameshotOpen] = useState(false);
|
||||
const [exports, setExports] = useState([]);
|
||||
const [file, setFile] = useState<File>(null);
|
||||
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
|
||||
|
||||
const getDataURL = (f: File): Promise<string> => {
|
||||
return new Promise((res, rej) => {
|
||||
@@ -372,6 +375,28 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
{totp_enabled && (
|
||||
<Box my='md'>
|
||||
<Title>Two Factor Authentication</Title>
|
||||
<MutedText size='md'>
|
||||
{user.totpSecret
|
||||
? 'You have two factor authentication enabled.'
|
||||
: 'You do not have two factor authentication enabled.'}
|
||||
</MutedText>
|
||||
|
||||
<Button size='lg' my='sm' onClick={() => setTotpOpen(true)}>
|
||||
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
|
||||
</Button>
|
||||
|
||||
<TotpModal
|
||||
opened={totpOpen}
|
||||
onClose={() => setTotpOpen(false)}
|
||||
deleteTotp={totpEnabled}
|
||||
setTotpEnabled={setTotpEnabled}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{oauth_registration && (
|
||||
<Box my='md'>
|
||||
<Title>OAuth</Title>
|
||||
|
||||
128
src/components/pages/MetadataView.tsx
Normal file
128
src/components/pages/MetadataView.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Button, Center, Group, Skeleton, Stack, Table, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function MetadataView({ fileId }) {
|
||||
const router = useRouter();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [metadata, setMetadata] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filtered, setFiltered] = useState([]);
|
||||
|
||||
const getMetadata = async () => {
|
||||
const data = await useFetch(`/api/exif?id=${fileId}`);
|
||||
if (!data.error) {
|
||||
const arr = [];
|
||||
for (const key in data) {
|
||||
arr.push({ name: key, value: data[key] });
|
||||
}
|
||||
setMetadata(arr);
|
||||
} else {
|
||||
setMetadata([]);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (value) => {
|
||||
clipboard.copy(value);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: value,
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const searchValue = (value) => {
|
||||
setSearch(value);
|
||||
|
||||
const filtered = metadata.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(value.toLowerCase()) ||
|
||||
item.value.toString().toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length > 0) {
|
||||
setFiltered(filtered);
|
||||
} else {
|
||||
setFiltered(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearch('');
|
||||
setFiltered([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMetadata();
|
||||
}, []);
|
||||
|
||||
const rows = (filtered?.length ? filtered : metadata).map((element) => (
|
||||
<tr key={element.name}>
|
||||
<td>{element.name}</td>
|
||||
<td>{element.value}</td>
|
||||
<td>
|
||||
<Button.Group>
|
||||
<Button variant='light' onClick={() => copy(element.value)}>
|
||||
Copy Value
|
||||
</Button>
|
||||
<Button variant='light' onClick={() => copy(element.name)}>
|
||||
Copy Name
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Metadata for {fileId}</Title>
|
||||
</Group>
|
||||
|
||||
{metadata ? (
|
||||
<>
|
||||
<TextInput
|
||||
my='md'
|
||||
label='Search'
|
||||
labelProps={{
|
||||
size: 'xl',
|
||||
}}
|
||||
placeholder='Search for a metadata value'
|
||||
value={search}
|
||||
onChange={(e) => searchValue(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{filtered === null ? (
|
||||
<Center>
|
||||
<Group spacing='md'>
|
||||
<Title>No results found</Title>
|
||||
<Button variant='outline' color='red' onClick={clearSearch}>
|
||||
Clear search
|
||||
</Button>
|
||||
</Group>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Skeleton height={300} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,27 +10,29 @@ import {
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import { ClockIcon, CrossIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { randomChars } from 'lib/utils/client';
|
||||
import { expireReadToDate, randomChars } from 'lib/utils/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
|
||||
export default function Upload({ chunks: chunks_config }) {
|
||||
export default function File({ chunks: chunks_config }) {
|
||||
const clipboard = useClipboard();
|
||||
const modals = useModals();
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expires, setExpires] = useState('never');
|
||||
const [password, setPassword] = useState('');
|
||||
const [maxViews, setMaxViews] = useState<number>(undefined);
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
@@ -110,21 +112,11 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
invalidateFiles();
|
||||
setFiles([]);
|
||||
setProgress(100);
|
||||
@@ -157,9 +149,16 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
req.setRequestHeader('X-Zipline-Partial-MimeType', file.type);
|
||||
req.setRequestHeader('X-Zipline-Partial-Identifier', identifier);
|
||||
req.setRequestHeader('X-Zipline-Partial-LastChunk', j === chunks.length - 1 ? 'true' : 'false');
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews &&
|
||||
options.maxViews !== 0 &&
|
||||
req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' &&
|
||||
req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
|
||||
req.send(body);
|
||||
|
||||
@@ -169,40 +168,7 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const expires_at =
|
||||
expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'5min': Date.now() + 5 * 60 * 1000,
|
||||
'10min': Date.now() + 10 * 60 * 1000,
|
||||
'15min': Date.now() + 15 * 60 * 1000,
|
||||
'30min': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'2h': Date.now() + 2 * 60 * 60 * 1000,
|
||||
'3h': Date.now() + 3 * 60 * 60 * 1000,
|
||||
'4h': Date.now() + 4 * 60 * 60 * 1000,
|
||||
'5h': Date.now() + 5 * 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'8h': Date.now() + 8 * 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,
|
||||
'1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
|
||||
'2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
'3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
|
||||
'1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
'1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
|
||||
'2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
|
||||
'3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires]
|
||||
);
|
||||
const expires_at = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
|
||||
setProgress(0);
|
||||
setLoading(true);
|
||||
@@ -254,25 +220,15 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
const json = JSON.parse(e.target.response);
|
||||
setLoading(false);
|
||||
|
||||
if (json.error === undefined) {
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.files[0]);
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
setFiles([]);
|
||||
invalidateFiles();
|
||||
|
||||
@@ -304,9 +260,16 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
if (bodyLength !== 0) {
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews &&
|
||||
options.maxViews !== 0 &&
|
||||
req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' &&
|
||||
req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
|
||||
req.send(body);
|
||||
}
|
||||
@@ -314,6 +277,7 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsModal />
|
||||
<Title mb='md'>Upload Files</Title>
|
||||
|
||||
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
|
||||
@@ -329,54 +293,12 @@ export default function Upload({ chunks: chunks_config }) {
|
||||
</Collapse>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Tooltip label='After the file reaches this amount of views, it will be deleted automatically. Leave blank for no limit.'>
|
||||
<NumberInput placeholder='Max Views' min={0} value={maxViews} onChange={(x) => setMaxViews(x)} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Add a password to your files (optional, leave blank for none)'>
|
||||
<PasswordInput
|
||||
style={{ width: '252px' }}
|
||||
placeholder='Password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Set an expiration date for your files (optional, defaults to never)'>
|
||||
<Select
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e)}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button onClick={() => setOpened(true)} variant='outline'>
|
||||
Options
|
||||
</Button>
|
||||
<Button onClick={() => setFiles([])} color='red' variant='outline'>
|
||||
Clear Files
|
||||
</Button>
|
||||
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={files.length === 0 ? true : false}>
|
||||
Upload
|
||||
</Button>
|
||||
127
src/components/pages/Upload/Text.tsx
Normal file
127
src/components/pages/Upload/Text.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Button, Group, NumberInput, PasswordInput, Select, Tabs, Title, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import CodeInput from 'components/CodeInput';
|
||||
import { ClockIcon, ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
|
||||
import exts from 'lib/exts';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { expireReadToDate } from 'lib/utils/client';
|
||||
import { Language } from 'prism-react-renderer';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
|
||||
export default function Text() {
|
||||
const clipboard = useClipboard();
|
||||
const modals = useModals();
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expires_at = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
|
||||
showNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Uploading...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', (e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Successful',
|
||||
message: '',
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
}
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('UploadText', 'true');
|
||||
|
||||
options.expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
options.password.trim() !== '' && req.setRequestHeader('Password', options.password);
|
||||
options.maxViews && options.maxViews !== 0 && req.setRequestHeader('Max-Views', String(options.maxViews));
|
||||
options.compression !== 'none' && req.setRequestHeader('Image-Compression-Percent', options.compression);
|
||||
options.embedded && req.setRequestHeader('Embed', 'true');
|
||||
options.zeroWidth && req.setRequestHeader('Zws', 'true');
|
||||
options.format !== 'default' && req.setRequestHeader('Format', options.format);
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<OptionsModal />
|
||||
<Title mb='md'>Upload Text</Title>
|
||||
|
||||
<Tabs defaultValue='text' variant='pills'>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='text' icon={<TypeIcon />}>
|
||||
Text
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value='preview' icon={<ImageIcon />}>
|
||||
Preview
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel mt='sm' value='text'>
|
||||
<CodeInput value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel mt='sm' value='preview'>
|
||||
<Prism
|
||||
sx={(t) => ({ height: '80vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={lang as Language}
|
||||
>
|
||||
{value}
|
||||
</Prism>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Select
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
dropdownPosition='top'
|
||||
data={Object.keys(exts).map((x) => ({ value: x, label: exts[x] }))}
|
||||
icon={<TypeIcon />}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Button onClick={() => setOpened(true)} variant='outline'>
|
||||
Options
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
leftIcon={<UploadIcon />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/components/pages/Upload/showFilesModal.tsx
Normal file
44
src/components/pages/Upload/showFilesModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Button, Table, Title } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
|
||||
export default function showFilesModal(clipboard, modals, files: string[]) {
|
||||
const open = (idx: number) => window.open(files[idx], '_blank');
|
||||
const copy = (idx: number) => {
|
||||
clipboard.copy(files[idx]);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <Link href={files[idx]}>{files[idx]}</Link>,
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
modals.openModal({
|
||||
title: <Title>Uploaded Files</Title>,
|
||||
size: 'auto',
|
||||
children: (
|
||||
<Table withBorder={false} withColumnBorders={false} highlightOnHover horizontalSpacing={'sm'}>
|
||||
<tbody>
|
||||
{files.map((file, idx) => (
|
||||
<tr key={file}>
|
||||
<td>
|
||||
<Link href={file}>{file}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Button.Group>
|
||||
<Button variant='outline' onClick={() => copy(idx)}>
|
||||
Copy
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => open(idx)}>
|
||||
Open
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
),
|
||||
});
|
||||
}
|
||||
174
src/components/pages/Upload/useUploadOptions.tsx
Normal file
174
src/components/pages/Upload/useUploadOptions.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { ClockIcon, ImageIcon, KeyIcon, TypeIcon, UserIcon } from 'components/icons';
|
||||
import React, { Dispatch, SetStateAction, useState } from 'react';
|
||||
|
||||
export default function useUploadOptions(): [
|
||||
{
|
||||
expires: string;
|
||||
password: string;
|
||||
maxViews: number;
|
||||
compression: string;
|
||||
zeroWidth: boolean;
|
||||
embedded: boolean;
|
||||
format: string;
|
||||
},
|
||||
Dispatch<SetStateAction<boolean>>,
|
||||
React.FC
|
||||
] {
|
||||
const [expires, setExpires] = useState('never');
|
||||
const [password, setPassword] = useState('');
|
||||
const [maxViews, setMaxViews] = useState(0);
|
||||
const [compression, setCompression] = useState<string>('none');
|
||||
const [zeroWidth, setZeroWidth] = useState(false);
|
||||
const [embedded, setEmbedded] = useState(false);
|
||||
const [format, setFormat] = useState('default');
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setExpires('never');
|
||||
setPassword('');
|
||||
setMaxViews(0);
|
||||
setCompression('none');
|
||||
setZeroWidth(false);
|
||||
setEmbedded(false);
|
||||
setFormat('default');
|
||||
};
|
||||
|
||||
const OptionsModal: React.FC = () => (
|
||||
<Modal title={<Title>Upload Options</Title>} size='auto' opened={opened} onClose={() => setOpened(false)}>
|
||||
<Stack>
|
||||
<NumberInput
|
||||
label='Max Views'
|
||||
description='The maximum number of times this file can be viewed. Leave blank for unlimited views.'
|
||||
value={maxViews}
|
||||
onChange={setMaxViews}
|
||||
min={0}
|
||||
icon={<UserIcon />}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Expires'
|
||||
description='The date and time this file will expire. Leave blank for never.'
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e)}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Compression'
|
||||
description='The compression level to use when uploading this file. Leave blank for default.'
|
||||
value={compression}
|
||||
onChange={(e) => setCompression(e)}
|
||||
icon={<ImageIcon />}
|
||||
data={[
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: '25', label: 'Low (25%)' },
|
||||
{ value: '50', label: 'Medium (50%)' },
|
||||
{ value: '75', label: 'High (75%)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Format'
|
||||
description="The file name format to use when uploading this file. Leave blank for the server's default."
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e)}
|
||||
icon={<TypeIcon />}
|
||||
data={[
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'RANDOM', label: 'Random' },
|
||||
{ value: 'NAME', label: 'Original Name' },
|
||||
{ value: 'DATE', label: 'Date (format configured by server)' },
|
||||
{ value: 'UUID', label: 'UUID' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='The password required to view this file. Leave blank for no password.'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
icon={<KeyIcon />}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Switch
|
||||
label='Zero Width'
|
||||
description='Whether or not to use zero width characters for the file name.'
|
||||
checked={zeroWidth}
|
||||
onChange={(e) => setZeroWidth(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Embedded'
|
||||
description='Whether or not to embed with OG tags for this file.'
|
||||
checked={embedded}
|
||||
onChange={(e) => setEmbedded(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Button onClick={() => reset()} color='red'>
|
||||
Reset Options
|
||||
</Button>
|
||||
<Button onClick={() => setOpened(false)}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
expires,
|
||||
password,
|
||||
maxViews,
|
||||
compression,
|
||||
zeroWidth,
|
||||
embedded,
|
||||
format,
|
||||
},
|
||||
setOpened,
|
||||
OptionsModal,
|
||||
];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { Button, Group, NumberInput, PasswordInput, Select, Tabs, Title, Tooltip } from '@mantine/core';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import CodeInput from 'components/CodeInput';
|
||||
import { ClockIcon, ImageIcon, TypeIcon, UploadIcon } from 'components/icons';
|
||||
import Link from 'components/Link';
|
||||
import exts from 'lib/exts';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { Language } from 'prism-react-renderer';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
export default function Upload() {
|
||||
const user = useRecoilValue(userSelector);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
const [password, setPassword] = useState('');
|
||||
const [expires, setExpires] = useState('never');
|
||||
const [maxViews, setMaxViews] = useState<number>(undefined);
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expires_at =
|
||||
expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'5min': Date.now() + 5 * 60 * 1000,
|
||||
'10min': Date.now() + 10 * 60 * 1000,
|
||||
'15min': Date.now() + 15 * 60 * 1000,
|
||||
'30min': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'2h': Date.now() + 2 * 60 * 60 * 1000,
|
||||
'3h': Date.now() + 3 * 60 * 60 * 1000,
|
||||
'4h': Date.now() + 4 * 60 * 60 * 1000,
|
||||
'5h': Date.now() + 5 * 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'8h': Date.now() + 8 * 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,
|
||||
'1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
|
||||
'2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
'3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
|
||||
'1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
'1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
|
||||
'2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
|
||||
'3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires]
|
||||
);
|
||||
|
||||
showNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Uploading...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', (e) => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
|
||||
if (!json.error) {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Successful',
|
||||
message: (
|
||||
<>
|
||||
Copied first file to clipboard! <br />
|
||||
{json.files.map((x) => (
|
||||
<Link key={x} href={x}>
|
||||
{x}
|
||||
<br />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.setRequestHeader('UploadText', 'true');
|
||||
|
||||
expires !== 'never' && req.setRequestHeader('Expires-At', 'date=' + expires_at.toISOString());
|
||||
password !== '' && req.setRequestHeader('Password', password);
|
||||
maxViews && maxViews !== 0 && req.setRequestHeader('Max-Views', String(maxViews));
|
||||
|
||||
req.send(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Upload Text</Title>
|
||||
|
||||
<Tabs defaultValue='text' variant='pills'>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value='text' icon={<TypeIcon />}>
|
||||
Text
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value='preview' icon={<ImageIcon />}>
|
||||
Preview
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel mt='sm' value='text'>
|
||||
<CodeInput value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel mt='sm' value='preview'>
|
||||
<Prism
|
||||
sx={(t) => ({ height: '80vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={lang as Language}
|
||||
>
|
||||
{value}
|
||||
</Prism>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Select
|
||||
value={lang}
|
||||
onChange={setLang}
|
||||
dropdownPosition='top'
|
||||
data={Object.keys(exts).map((x) => ({ value: x, label: exts[x] }))}
|
||||
icon={<TypeIcon />}
|
||||
searchable
|
||||
/>
|
||||
<Tooltip label='After the file reaches this amount of views, it will be deleted automatically. Leave blank for no limit.'>
|
||||
<NumberInput placeholder='Max Views' min={0} value={maxViews} onChange={(x) => setMaxViews(x)} />
|
||||
</Tooltip>
|
||||
<Tooltip label='Add a password to your files (optional, leave blank for none)'>
|
||||
<PasswordInput
|
||||
style={{ width: '252px' }}
|
||||
placeholder='Password'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label='Set an expiration date for your files (optional, defaults to never)'>
|
||||
<Select
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e)}
|
||||
icon={<ClockIcon size={14} />}
|
||||
data={[
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
{ value: '15min', label: '15 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '2h', label: '2 hours' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '4h', label: '4 hours' },
|
||||
{ value: '5h', label: '5 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '8h', label: '8 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
{ value: '1.5w', label: '1.5 weeks' },
|
||||
{ value: '2w', label: '2 weeks' },
|
||||
{ value: '3w', label: '3 weeks' },
|
||||
{ value: '1m', label: '1 month' },
|
||||
{ value: '1.5m', label: '1.5 months' },
|
||||
{ value: '2m', label: '2 months' },
|
||||
{ value: '3m', label: '3 months' },
|
||||
{ value: '6m', label: '6 months' },
|
||||
{ value: '8m', label: '8 months' },
|
||||
{ value: '1y', label: '1 year' },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftIcon={<UploadIcon />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,10 @@ export interface ConfigCore {
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
type: 'local' | 's3' | 'swift';
|
||||
type: 'local' | 's3' | 'supabase';
|
||||
local: ConfigLocalDatasource;
|
||||
s3?: ConfigS3Datasource;
|
||||
swift?: ConfigSwiftDatasource;
|
||||
supabase?: ConfigSupabaseDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
@@ -32,23 +32,21 @@ export interface ConfigS3Datasource {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSwiftDatasource {
|
||||
container: string;
|
||||
auth_endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id?: string;
|
||||
region_id?: string;
|
||||
export interface ConfigSupabaseDatasource {
|
||||
url: string;
|
||||
key: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
default_format: string;
|
||||
route: string;
|
||||
length: number;
|
||||
admin_limit: number;
|
||||
user_limit: number;
|
||||
disabled_extensions: string[];
|
||||
format_date: string;
|
||||
default_expiration: string;
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
@@ -105,6 +103,8 @@ export interface ConfigFeatures {
|
||||
|
||||
oauth_registration: boolean;
|
||||
user_registration: boolean;
|
||||
|
||||
headless: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
@@ -123,6 +123,16 @@ export interface ConfigChunks {
|
||||
chunks_size: number;
|
||||
}
|
||||
|
||||
export interface ConfigMfa {
|
||||
totp_enabled: boolean;
|
||||
totp_issuer: string;
|
||||
}
|
||||
|
||||
export interface ConfigExif {
|
||||
enabled: boolean;
|
||||
remove_gps: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
@@ -134,4 +144,6 @@ export interface Config {
|
||||
oauth: ConfigOAuth;
|
||||
features: ConfigFeatures;
|
||||
chunks: ConfigChunks;
|
||||
mfa: ConfigMfa;
|
||||
exif: ConfigExif;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expand } from 'dotenv-expand';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import Logger from '../logger';
|
||||
import { humanToBytes } from '../utils/bytes';
|
||||
import { parseExpiry } from '../utils/client';
|
||||
|
||||
export type ValueType = 'string' | 'number' | 'boolean' | 'array' | 'json-array' | 'human-to-byte';
|
||||
|
||||
@@ -77,20 +78,18 @@ export default function readConfig() {
|
||||
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
|
||||
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
|
||||
|
||||
map('DATASOURCE_SWIFT_USERNAME', 'string', 'datasource.swift.username'),
|
||||
map('DATASOURCE_SWIFT_PASSWORD', 'string', 'datasource.swift.password'),
|
||||
map('DATASOURCE_SWIFT_AUTH_ENDPOINT', 'string', 'datasource.swift.auth_endpoint'),
|
||||
map('DATASOURCE_SWIFT_CONTAINER', 'string', 'datasource.swift.container'),
|
||||
map('DATASOURCE_SWIFT_PROJECT_ID', 'string', 'datasource.swift.project_id'),
|
||||
map('DATASOURCE_SWIFT_DOMAIN_ID', 'string', 'datasource.swift.domain_id'),
|
||||
map('DATASOURCE_SWIFT_REGION_ID', 'string', 'datasource.swift.region_id'),
|
||||
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
|
||||
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
|
||||
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
|
||||
|
||||
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
|
||||
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
|
||||
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
|
||||
map('UPLOADER_ADMIN_LIMIT', 'human-to-byte', 'uploader.admin_limit'),
|
||||
map('UPLOADER_USER_LIMIT', 'human-to-byte', 'uploader.user_limit'),
|
||||
map('UPLOADER_DISABLED_EXTENSIONS', 'array', 'uploader.disabled_extensions'),
|
||||
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
|
||||
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
|
||||
|
||||
map('URLS_ROUTE', 'string', 'urls.route'),
|
||||
map('URLS_LENGTH', 'number', 'urls.length'),
|
||||
@@ -141,8 +140,16 @@ export default function readConfig() {
|
||||
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
|
||||
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
|
||||
|
||||
map('FEATURES_HEADLESS', 'boolean', 'features.headless'),
|
||||
|
||||
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
|
||||
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
|
||||
|
||||
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
|
||||
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),
|
||||
|
||||
map('EXIF_ENABLED', 'boolean', 'exif.enabled'),
|
||||
map('EXIF_REMOVE_GPS', 'boolean', 'exif.remove_gps'),
|
||||
];
|
||||
|
||||
const config = {};
|
||||
|
||||
@@ -34,7 +34,7 @@ const validator = s.object({
|
||||
}),
|
||||
datasource: s
|
||||
.object({
|
||||
type: s.enum('local', 's3', 'swift').default('local'),
|
||||
type: s.enum('local', 's3', 'supabase').default('local'),
|
||||
local: s
|
||||
.object({
|
||||
directory: s.string.default('./uploads'),
|
||||
@@ -52,14 +52,10 @@ const validator = s.object({
|
||||
region: s.string.default('us-east-1'),
|
||||
use_ssl: s.boolean.default(false),
|
||||
}).optional,
|
||||
swift: s.object({
|
||||
username: s.string,
|
||||
password: s.string,
|
||||
auth_endpoint: s.string,
|
||||
container: s.string,
|
||||
project_id: s.string,
|
||||
domain_id: s.string.default('default'),
|
||||
region_id: s.string.nullable,
|
||||
supabase: s.object({
|
||||
url: s.string,
|
||||
key: s.string,
|
||||
bucket: s.string,
|
||||
}).optional,
|
||||
})
|
||||
.default({
|
||||
@@ -71,12 +67,10 @@ const validator = s.object({
|
||||
region: 'us-east-1',
|
||||
force_s3_path: false,
|
||||
},
|
||||
swift: {
|
||||
domain_id: 'default',
|
||||
},
|
||||
}),
|
||||
uploader: s
|
||||
.object({
|
||||
default_format: s.string.default('RANDOM'),
|
||||
route: s.string.default('/u'),
|
||||
embed_route: s.string.default('/a'),
|
||||
length: s.number.default(6),
|
||||
@@ -84,8 +78,10 @@ const validator = s.object({
|
||||
user_limit: s.number.default(humanToBytes('100MB')),
|
||||
disabled_extensions: s.string.array.default([]),
|
||||
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
|
||||
default_expiration: s.string.optional.default(null),
|
||||
})
|
||||
.default({
|
||||
default_format: 'RANDOM',
|
||||
route: '/u',
|
||||
embed_route: '/a',
|
||||
length: 6,
|
||||
@@ -93,6 +89,7 @@ const validator = s.object({
|
||||
user_limit: humanToBytes('100MB'),
|
||||
disabled_extensions: [],
|
||||
format_date: 'YYYY-MM-DD_HH:mm:ss',
|
||||
default_expiration: null,
|
||||
}),
|
||||
urls: s
|
||||
.object({
|
||||
@@ -171,8 +168,15 @@ const validator = s.object({
|
||||
invites_length: s.number.default(6),
|
||||
oauth_registration: s.boolean.default(false),
|
||||
user_registration: s.boolean.default(false),
|
||||
headless: s.boolean.default(false),
|
||||
})
|
||||
.default({ invites: false, invites_length: 6, oauth_registration: false, user_registration: false }),
|
||||
.default({
|
||||
invites: false,
|
||||
invites_length: 6,
|
||||
oauth_registration: false,
|
||||
user_registration: false,
|
||||
headless: false,
|
||||
}),
|
||||
chunks: s
|
||||
.object({
|
||||
max_size: s.number.default(humanToBytes('90MB')),
|
||||
@@ -182,6 +186,24 @@ const validator = s.object({
|
||||
max_size: humanToBytes('90MB'),
|
||||
chunks_size: humanToBytes('20MB'),
|
||||
}),
|
||||
mfa: s
|
||||
.object({
|
||||
totp_issuer: s.string.default('Zipline'),
|
||||
totp_enabled: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
totp_issuer: 'Zipline',
|
||||
totp_enabled: false,
|
||||
}),
|
||||
exif: s
|
||||
.object({
|
||||
enabled: s.boolean.default(false),
|
||||
remove_gps: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
enabled: false,
|
||||
remove_gps: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function validate(config): Config {
|
||||
@@ -203,19 +225,15 @@ export default function validate(config): Config {
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'swift': {
|
||||
case 'supabase': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.swift.container)
|
||||
errors.push('datasource.swift.container is a required field');
|
||||
if (!validated.datasource.swift.project_id)
|
||||
errors.push('datasource.swift.project_id is a required field');
|
||||
if (!validated.datasource.swift.auth_endpoint)
|
||||
errors.push('datasource.swift.auth_endpoint is a required field');
|
||||
if (!validated.datasource.swift.password)
|
||||
errors.push('datasource.swift.password is a required field');
|
||||
if (!validated.datasource.swift.username)
|
||||
errors.push('datasource.swift.username is a required field');
|
||||
|
||||
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
|
||||
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
|
||||
if (!validated.datasource.supabase.bucket)
|
||||
errors.push('datasource.supabase.bucket is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -224,6 +242,8 @@ export default function validate(config): Config {
|
||||
} catch (e) {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
|
||||
|
||||
logger.debug(`config error: ${inspect(e, { depth: Infinity })}`);
|
||||
|
||||
e.stack = '';
|
||||
|
||||
Logger.get('config')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import config from './config';
|
||||
import { Datasource, Local, S3, Swift } from './datasources';
|
||||
import { Datasource, Local, S3, Supabase } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
const logger = Logger.get('datasource');
|
||||
@@ -14,9 +14,9 @@ if (!global.datasource) {
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
case 'swift':
|
||||
global.datasource = new Swift(config.datasource.swift);
|
||||
logger.info(`using Swift(${config.datasource.swift.container}) datasource`);
|
||||
case 'supabase':
|
||||
global.datasource = new Supabase(config.datasource.supabase);
|
||||
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
|
||||
104
src/lib/datasources/Supabase.ts
Normal file
104
src/lib/datasources/Supabase.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Datasource } from '.';
|
||||
import { ConfigSupabaseDatasource } from 'lib/config/Config';
|
||||
import { guess } from '../mimes';
|
||||
import Logger from '../logger';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export class Supabase extends Datasource {
|
||||
public name: string = 'Supabase';
|
||||
public logger: Logger = Logger.get('datasource::supabase');
|
||||
|
||||
public constructor(public config: ConfigSupabaseDatasource) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
const mimetype = await guess(file.split('.').pop());
|
||||
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': mimetype,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
|
||||
const j = await r.json();
|
||||
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async get(file: string): Promise<Readable> {
|
||||
// get a readable stream from the request
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
|
||||
return Readable.fromWeb(r.body as any);
|
||||
}
|
||||
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise(async (res, rej) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
search: file,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
if (j.length === 0) {
|
||||
res(0);
|
||||
} else {
|
||||
res(j[0].metadata.size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
res(j.reduce((a, b) => a + b.metadata.size, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigSwiftDatasource } from 'lib/config/Config';
|
||||
|
||||
interface SwiftContainerOptions {
|
||||
auth_endpoint_url: string;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id: string;
|
||||
container: string;
|
||||
interface?: string;
|
||||
region_id: string;
|
||||
};
|
||||
refreshMargin?: number;
|
||||
}
|
||||
|
||||
interface SwiftAuth {
|
||||
token: string;
|
||||
expires: Date;
|
||||
swiftURL: string;
|
||||
}
|
||||
|
||||
interface SwiftObject {
|
||||
bytes: number;
|
||||
content_type: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
class SwiftContainer {
|
||||
auth: SwiftAuth | null;
|
||||
|
||||
constructor(private options: SwiftContainerOptions) {
|
||||
this.auth = null;
|
||||
}
|
||||
|
||||
private findEndpointURL(catalog: any[], service: string): string | null {
|
||||
const catalogEntry = catalog.find((x) => x.name === service);
|
||||
if (!catalogEntry) return null;
|
||||
|
||||
const endpoint = catalogEntry.endpoints.find(
|
||||
(x: any) =>
|
||||
x.interface === (this.options.credentials.interface || 'public') &&
|
||||
(this.options.credentials.region_id ? x.region_id == this.options.credentials.region_id : true)
|
||||
);
|
||||
|
||||
return endpoint ? endpoint.url : null;
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<SwiftAuth> {
|
||||
const payload = {
|
||||
auth: {
|
||||
identity: {
|
||||
methods: ['password'],
|
||||
password: {
|
||||
user: {
|
||||
name: this.options.credentials.username,
|
||||
password: this.options.credentials.password,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
project: {
|
||||
id: this.options.credentials.project_id,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { json, headers, error } = await fetch(`${this.options.auth_endpoint_url}/auth/tokens`, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(async (e) => {
|
||||
try {
|
||||
const json = await e.json();
|
||||
return { json, headers: e.headers, error: null };
|
||||
} catch (e) {
|
||||
return { json: null, headers: null, error: e };
|
||||
}
|
||||
});
|
||||
|
||||
if (error || !json || !headers || json.error)
|
||||
throw new Error('Could not retrieve credentials from OpenStack, check your config file');
|
||||
|
||||
const catalog = json.token.catalog;
|
||||
// many Swift clouds use ceph radosgw to provide swift
|
||||
const swiftURL = this.findEndpointURL(catalog, 'swift') || this.findEndpointURL(catalog, 'radosgw-swift');
|
||||
if (!swiftURL) throw new Error('Couldn\'t find any "swift" or "radosgw-swift" service in the catalog');
|
||||
|
||||
return {
|
||||
token: headers.get('x-subject-token'),
|
||||
expires: new Date(json.token.expires_at),
|
||||
swiftURL,
|
||||
};
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
if (!this.auth) this.auth = await this.getCredentials();
|
||||
const authExpiry = new Date(Date.now() + this.options.refreshMargin || 10_000);
|
||||
|
||||
if (authExpiry > this.auth.expires) this.auth = await this.getCredentials();
|
||||
const validAuth = this.auth;
|
||||
|
||||
return { swiftURL: validAuth.swiftURL, token: validAuth.token };
|
||||
}
|
||||
|
||||
private generateHeaders(token: string, extra?: any) {
|
||||
return { accept: 'application/json', 'x-auth-token': token, ...extra };
|
||||
}
|
||||
|
||||
public async listObjects(query?: string): Promise<SwiftObject[]> {
|
||||
const auth = await this.authenticate();
|
||||
return await fetch(
|
||||
`${auth.swiftURL}/${this.options.credentials.container}${
|
||||
query ? `${query.startsWith('?') ? '' : '?'}${query}` : ''
|
||||
}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
}
|
||||
).then((e) => e.json());
|
||||
}
|
||||
|
||||
public async uploadObject(name: string, data: Buffer): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteObject(name: string): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
});
|
||||
}
|
||||
|
||||
public async getObject(name: string): Promise<Readable> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
const arrayBuffer = await fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token, { Accept: '*/*' }),
|
||||
}).then((e) => e.arrayBuffer());
|
||||
|
||||
return Readable.from(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
public async headObject(name: string): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'HEAD',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Swift extends Datasource {
|
||||
public name: string = 'Swift';
|
||||
container: SwiftContainer;
|
||||
|
||||
public constructor(public config: ConfigSwiftDatasource) {
|
||||
super();
|
||||
this.container = new SwiftContainer({
|
||||
auth_endpoint_url: config.auth_endpoint,
|
||||
credentials: {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
project_id: config.project_id,
|
||||
domain_id: config.domain_id || 'default',
|
||||
container: config.container,
|
||||
region_id: config.region_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
try {
|
||||
return this.container.uploadObject(file, data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
try {
|
||||
return this.container.deleteObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> | Readable {
|
||||
try {
|
||||
return this.container.getObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
try {
|
||||
const head = await this.container.headObject(file);
|
||||
|
||||
return head.headers.get('content-length') || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return this.container
|
||||
.listObjects()
|
||||
.then((objects) => objects.reduce((acc, object) => acc + object.bytes, 0))
|
||||
.catch(() => 0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
export { Swift } from './Swift';
|
||||
export { Supabase } from './Supabase';
|
||||
|
||||
@@ -2,13 +2,33 @@ import config from 'lib/config';
|
||||
import { notNull } from 'lib/util';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
// this entire thing will also probably change before the stable release
|
||||
export type OauthProvider = {
|
||||
name: string;
|
||||
url: string;
|
||||
link_url: string;
|
||||
};
|
||||
|
||||
export type ServerSideProps = {
|
||||
title: string;
|
||||
external_links: string;
|
||||
disable_media_preview: boolean;
|
||||
invites: boolean;
|
||||
user_registration: boolean;
|
||||
oauth_registration: boolean;
|
||||
oauth_providers: string;
|
||||
chunks_size: number;
|
||||
max_size: number;
|
||||
totp_enabled: boolean;
|
||||
exif_enabled: boolean;
|
||||
fileId?: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
|
||||
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret);
|
||||
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret);
|
||||
const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
|
||||
|
||||
const oauth_providers = [];
|
||||
const oauth_providers: OauthProvider[] = [];
|
||||
|
||||
if (ghEnabled)
|
||||
oauth_providers.push({
|
||||
@@ -30,7 +50,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
link_url: '/api/auth/oauth/google?state=link',
|
||||
});
|
||||
|
||||
return {
|
||||
const obj = {
|
||||
props: {
|
||||
title: config.website.title,
|
||||
external_links: JSON.stringify(config.website.external_links),
|
||||
@@ -41,6 +61,15 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
oauth_providers: JSON.stringify(oauth_providers),
|
||||
chunks_size: config.chunks.chunks_size,
|
||||
max_size: config.chunks.max_size,
|
||||
},
|
||||
totp_enabled: config.mfa.totp_enabled,
|
||||
exif_enabled: config.exif.enabled,
|
||||
} as ServerSideProps,
|
||||
};
|
||||
|
||||
if (ctx.resolvedUrl.startsWith('/dashboard/metadata')) {
|
||||
if (!config.exif.enabled) return { notFound: true };
|
||||
obj.props.fileId = ctx.query.id as string;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OauthProviders } from '@prisma/client';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { createToken } from 'lib/util';
|
||||
@@ -30,6 +31,12 @@ export const withOAuth =
|
||||
const logger = Logger.get(`oauth::${provider}`);
|
||||
|
||||
function oauthError(error: string) {
|
||||
if (config.features.headless)
|
||||
return res.json({
|
||||
error,
|
||||
provider,
|
||||
});
|
||||
|
||||
return res.redirect(`/oauth_error?error=${error}&provider=${provider}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HTTPMethod } from 'find-my-way';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { sign64, unsign64 } from 'lib/utils/crypto';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
export interface NextApiFile {
|
||||
fieldname: string;
|
||||
@@ -58,10 +59,15 @@ export const withZipline =
|
||||
api_config: ZiplineApiConfig = { methods: ['GET'] }
|
||||
) =>
|
||||
(req: NextApiReq, res: NextApiRes) => {
|
||||
api_config.methods.push('OPTIONS');
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
|
||||
res.setHeader('Access-Content-Allow-Methods', api_config.methods.join(','));
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
|
||||
// Used when the client sends wrong information, etc.
|
||||
res.badRequest = (message: string, extra: Record<string, any> = {}) => {
|
||||
@@ -137,6 +143,7 @@ export const withZipline =
|
||||
const unsigned = unsign64(cookie, config.core.secret);
|
||||
return unsigned ? unsigned : null;
|
||||
};
|
||||
|
||||
req.cleanCookie = (name: string) => {
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
@@ -150,6 +157,18 @@ export const withZipline =
|
||||
|
||||
req.user = async () => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token: authHeader,
|
||||
},
|
||||
include: { oauth: true },
|
||||
});
|
||||
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
const userId = req.getCookie('user');
|
||||
if (!userId) return null;
|
||||
|
||||
@@ -180,6 +199,8 @@ export const withZipline =
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
|
||||
Logger.get('api').debug(`headers(${JSON.stringify(req.headers)}): cookie(${name}, ${value})`);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { OAuth } from '@prisma/client';
|
||||
import type { UserExtended } from 'middleware/withZipline';
|
||||
import { atom, selector } from 'recoil';
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
domains: string[];
|
||||
avatar?: string;
|
||||
administrator: boolean;
|
||||
superAdmin: boolean;
|
||||
oauth: OAuth[];
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const userState = atom({
|
||||
key: 'userState',
|
||||
default: null as User,
|
||||
default: null as UserExtended,
|
||||
});
|
||||
|
||||
export const userSelector = selector<User>({
|
||||
export const userSelector = selector<UserExtended>({
|
||||
key: 'userSelector',
|
||||
get: ({ get }) => get(userState),
|
||||
set: ({ set }, newValue) => set(userState, newValue),
|
||||
|
||||
@@ -102,3 +102,39 @@ export function expireText(to_: string, from_: string = new Date().toLocaleStrin
|
||||
return `Expired ${dayjs(from).to(to)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function expireReadToDate(expires: string): Date {
|
||||
if (expires === 'never') return null;
|
||||
|
||||
return new Date(
|
||||
{
|
||||
'5min': Date.now() + 5 * 60 * 1000,
|
||||
'10min': Date.now() + 10 * 60 * 1000,
|
||||
'15min': Date.now() + 15 * 60 * 1000,
|
||||
'30min': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'2h': Date.now() + 2 * 60 * 60 * 1000,
|
||||
'3h': Date.now() + 3 * 60 * 60 * 1000,
|
||||
'4h': Date.now() + 4 * 60 * 60 * 1000,
|
||||
'5h': Date.now() + 5 * 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'8h': Date.now() + 8 * 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,
|
||||
'1w': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
'1.5w': Date.now() + 1.5 * 7 * 24 * 60 * 60 * 1000,
|
||||
'2w': Date.now() + 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
'3w': Date.now() + 3 * 7 * 24 * 60 * 60 * 1000,
|
||||
'1m': Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
'1.5m': Date.now() + 1.5 * 30 * 24 * 60 * 60 * 1000,
|
||||
'2m': Date.now() + 2 * 30 * 24 * 60 * 60 * 1000,
|
||||
'3m': Date.now() + 3 * 30 * 24 * 60 * 60 * 1000,
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires]
|
||||
);
|
||||
}
|
||||
|
||||
86
src/lib/utils/exif.ts
Normal file
86
src/lib/utils/exif.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Image } from '@prisma/client';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { exiftool, Tags } from 'exiftool-vendored';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
|
||||
const logger = Logger.get('exif');
|
||||
|
||||
export async function readMetadata(filePath: string): Promise<Tags> {
|
||||
const exif = await exiftool.read(filePath);
|
||||
logger.debug(`exif(${filePath}) -> ${JSON.stringify(exif)}`);
|
||||
|
||||
for (const key in exif) {
|
||||
if (exif[key]?.rawValue) {
|
||||
exif[key] = exif[key].rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
delete exif.Directory;
|
||||
delete exif.Source;
|
||||
delete exif.SourceFile;
|
||||
delete exif.errors;
|
||||
delete exif.Warning;
|
||||
|
||||
return exif;
|
||||
}
|
||||
|
||||
export async function removeGPSData(image: Image): Promise<void> {
|
||||
const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.file}`);
|
||||
logger.debug(`writing temp file to remove GPS data: ${file}`);
|
||||
|
||||
const stream = await datasource.get(image.file);
|
||||
const writeStream = createWriteStream(file);
|
||||
stream.pipe(writeStream);
|
||||
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve));
|
||||
|
||||
logger.debug(`removing GPS data from ${file}`);
|
||||
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,
|
||||
GPSMeasureMode: 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.file}`);
|
||||
const buffer = await readFile(file);
|
||||
await datasource.save(image.file, buffer);
|
||||
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await unlink(file);
|
||||
|
||||
return;
|
||||
}
|
||||
16
src/lib/utils/totp.ts
Normal file
16
src/lib/utils/totp.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { authenticator } from 'otplib';
|
||||
import { toDataURL } from 'qrcode';
|
||||
|
||||
export function generate_totp_secret() {
|
||||
return authenticator.generateSecret(32);
|
||||
}
|
||||
|
||||
export function verify_totp_code(secret: string, code: string) {
|
||||
return authenticator.check(code, secret);
|
||||
}
|
||||
|
||||
export async function totp_qrcode(issuer: string, username: string, secret: string): Promise<string> {
|
||||
const url = authenticator.keyuri(username, issuer, secret);
|
||||
|
||||
return toDataURL(url);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import { checkPassword, createToken, hashPassword } from 'lib/util';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { checkPassword, createToken, hashPassword } from 'lib/util';
|
||||
import { verify_totp_code } from 'lib/utils/totp';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const logger = Logger.get('login');
|
||||
|
||||
const { username, password } = req.body as {
|
||||
const { username, password, code } = req.body as {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany();
|
||||
@@ -33,9 +36,25 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
if (!user) return res.notFound('user not found');
|
||||
|
||||
const valid = await checkPassword(password, user.password);
|
||||
let valid = false;
|
||||
if (user.token === password) valid = true;
|
||||
else if (await checkPassword(password, user.password)) valid = true;
|
||||
else valid = false;
|
||||
|
||||
logger.debug(`body(${JSON.stringify(req.body)}): checkPassword(${password}, argon2-str) => ${valid}`);
|
||||
|
||||
if (!valid) return res.unauthorized('Wrong password');
|
||||
|
||||
if (user.totpSecret && config.mfa.totp_enabled) {
|
||||
if (!code) return res.unauthorized('TOTP required', { totp: true });
|
||||
|
||||
const success = verify_totp_code(user.totpSecret, code);
|
||||
logger.debug(
|
||||
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`
|
||||
);
|
||||
if (!success) return res.badRequest('Invalid code', { totp: true });
|
||||
}
|
||||
|
||||
res.setUserCookie(user.id);
|
||||
logger.info(`User ${user.username} (${user.id}) logged in`);
|
||||
|
||||
|
||||
66
src/pages/api/exif.ts
Normal file
66
src/pages/api/exif.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createWriteStream, existsSync } from 'fs';
|
||||
import { unlink } from 'fs/promises';
|
||||
import config from 'lib/config';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { readMetadata } from 'lib/utils/exif';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = Logger.get('exif');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (!config.exif.enabled) return res.forbidden('exif disabled');
|
||||
|
||||
const { id } = req.query as { id: string };
|
||||
if (!id) return res.badRequest('no id');
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
id: Number(id),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) return res.notFound('image not found');
|
||||
|
||||
logger.info(
|
||||
`${user.username} (${user.id}) requested to read exif metadata for image ${image.file} (${image.id})`
|
||||
);
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
const filePath = join(config.datasource.local.directory, image.file);
|
||||
logger.debug(`attemping to read exif metadata from ${filePath}`);
|
||||
|
||||
if (!existsSync(filePath)) return res.notFound('image not found on fs');
|
||||
|
||||
const data = await readMetadata(filePath);
|
||||
logger.debug(`exif(${filePath}) -> ${JSON.stringify(data)}`);
|
||||
|
||||
return res.json(data);
|
||||
} else {
|
||||
const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.file}`);
|
||||
logger.debug(`writing temp file to view metadata: ${file}`);
|
||||
|
||||
const stream = await datasource.get(image.file);
|
||||
const writeStream = createWriteStream(file);
|
||||
stream.pipe(writeStream);
|
||||
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve));
|
||||
|
||||
const data = await readMetadata(file);
|
||||
logger.debug(`exif(${file}) -> ${JSON.stringify(data)}`);
|
||||
|
||||
await unlink(file);
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
|
||||
return res.json(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET'],
|
||||
user: true,
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline'
|
||||
import prisma from 'lib/prisma';
|
||||
import { createInvisImage, hashPassword, randomChars } from 'lib/util';
|
||||
import { parseExpiry } from 'lib/utils/client';
|
||||
import { removeGPSData } from 'lib/utils/exif';
|
||||
import multer from 'multer';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
@@ -36,7 +37,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
});
|
||||
});
|
||||
|
||||
const response: { files: string[]; expires_at?: Date } = { files: [] };
|
||||
const response: { files: string[]; expires_at?: Date; removed_gps?: boolean } = { files: [] };
|
||||
const expires_at = req.headers['expires-at'] as string;
|
||||
let expiry: Date;
|
||||
|
||||
@@ -48,7 +49,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
}
|
||||
}
|
||||
|
||||
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || 'RANDOM';
|
||||
if (zconfig.uploader.default_expiration) {
|
||||
expiry = parseExpiry(zconfig.uploader.default_expiration);
|
||||
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)');
|
||||
}
|
||||
|
||||
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || zconfig.uploader.default_format;
|
||||
const format: ImageFormat = Object.keys(ImageFormat).includes(rawFormat) && ImageFormat[rawFormat];
|
||||
|
||||
const imageCompressionPercent = req.headers['image-compression-percent']
|
||||
@@ -115,7 +121,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
chunks.set(buffer, chunkData.start);
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop();
|
||||
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
|
||||
if (zconfig.uploader.disabled_extensions.includes(ext))
|
||||
return res.error('disabled extension recieved: ' + ext);
|
||||
let fileName: string;
|
||||
@@ -148,7 +154,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
const file = await prisma.image.create({
|
||||
data: {
|
||||
file: `${fileName}.${compressionUsed ? 'jpg' : ext}`,
|
||||
file: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
|
||||
mimetype,
|
||||
userId: user.id,
|
||||
embed: !!req.headers.embed,
|
||||
@@ -189,6 +195,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
);
|
||||
}
|
||||
|
||||
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
|
||||
await removeGPSData(file);
|
||||
response.removed_gps = true;
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
}
|
||||
|
||||
@@ -235,7 +246,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
return res.badRequest(`file[${i}]: size too big`);
|
||||
if (!file.originalname) return res.badRequest(`file[${i}]: no filename`);
|
||||
|
||||
const ext = file.originalname.split('.').pop();
|
||||
const ext = file.originalname.split('.').length === 1 ? '' : file.originalname.split('.').pop();
|
||||
if (zconfig.uploader.disabled_extensions.includes(ext))
|
||||
return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`);
|
||||
let fileName: string;
|
||||
@@ -267,7 +278,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
let invis: InvisibleImage;
|
||||
const image = await prisma.image.create({
|
||||
data: {
|
||||
file: `${fileName}.${compressionUsed ? 'jpg' : ext}`,
|
||||
file: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
|
||||
mimetype: req.headers.uploadtext ? 'text/plain' : compressionUsed ? 'image/jpeg' : file.mimetype,
|
||||
userId: user.id,
|
||||
embed: !!req.headers.embed,
|
||||
@@ -315,6 +326,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
`${zconfig.core.https ? 'https' : 'http'}://${req.headers.host}/r/${invis ? invis.invis : image.file}`
|
||||
);
|
||||
}
|
||||
|
||||
if (zconfig.exif.enabled && zconfig.exif.remove_gps && image.mimetype.startsWith('image/')) {
|
||||
await removeGPSData(image);
|
||||
response.removed_gps = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (user.administrator && zconfig.ratelimit.admin > 0) {
|
||||
|
||||
82
src/pages/api/user/mfa/totp.ts
Normal file
82
src/pages/api/user/mfa/totp.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { generate_totp_secret, totp_qrcode, verify_totp_code } from 'lib/utils/totp';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const logger = Logger.get('user::mfa::totp');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (!config.mfa.totp_enabled) return res.forbidden('totp is disabled');
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const { secret, code } = req.body as { secret: string; code: string };
|
||||
|
||||
if (!secret) return res.badRequest('no secret');
|
||||
if (!code) return res.badRequest('no code');
|
||||
|
||||
if (code.length !== 6) return res.badRequest('invalid code (code.length != 6)');
|
||||
|
||||
const success = verify_totp_code(secret, code);
|
||||
logger.debug(`body(${JSON.stringify(req.body)}): verify_totp_code(${secret}, ${code}) => ${success}`);
|
||||
|
||||
if (!success) return res.badRequest('Invalid code');
|
||||
if (user.totpSecret) return res.badRequest('totp already registered');
|
||||
|
||||
logger.debug(`registering totp(${secret}) ${user.id}`);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
delete user.password;
|
||||
return res.json(user);
|
||||
} else if (req.method === 'DELETE') {
|
||||
const { code } = req.body as { code: string };
|
||||
|
||||
if (!code) return res.badRequest('no code');
|
||||
if (code.length !== 6) return res.badRequest('invalid code (code.length != 6)');
|
||||
|
||||
const success = verify_totp_code(user.totpSecret, req.body.code);
|
||||
|
||||
logger.debug(
|
||||
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${
|
||||
req.body.code
|
||||
}) => ${success}`
|
||||
);
|
||||
|
||||
if (!success) return res.badRequest('Invalid code');
|
||||
|
||||
logger.debug(`unregistering totp ${user.id}`);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
delete user.password;
|
||||
return res.json(user);
|
||||
} else {
|
||||
if (!user.totpSecret) {
|
||||
const secret = generate_totp_secret();
|
||||
const data_url = await totp_qrcode(config.mfa.totp_issuer, user.username, secret);
|
||||
|
||||
return res.json({
|
||||
secret,
|
||||
data_url,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
secret: user.totpSecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
@@ -1,16 +1,32 @@
|
||||
import { Button, Center, Divider, PasswordInput, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
CheckIcon,
|
||||
Divider,
|
||||
Modal,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { DiscordIcon, GitHubIcon, GoogleIcon } from 'components/icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
|
||||
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) {
|
||||
const router = useRouter();
|
||||
|
||||
// totp modal
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [code, setCode] = useState(undefined);
|
||||
const [error, setError] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
|
||||
const oauth_providers = JSON.parse(unparsed);
|
||||
|
||||
const icons = {
|
||||
@@ -31,6 +47,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
setError('');
|
||||
const username = values.username.trim();
|
||||
const password = values.password.trim();
|
||||
|
||||
@@ -39,11 +56,20 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username,
|
||||
password,
|
||||
code: code?.toString() || null,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
if (res.code === 403) {
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else if (res.totp) {
|
||||
if (res.code === 400) {
|
||||
setError('Invalid code');
|
||||
} else {
|
||||
setError('');
|
||||
}
|
||||
|
||||
setTotpOpen(true);
|
||||
} else {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
@@ -66,6 +92,35 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
<Head>
|
||||
<title>{full_title}</title>
|
||||
</Head>
|
||||
<Modal
|
||||
opened={totpOpen}
|
||||
onClose={() => setTotpOpen(false)}
|
||||
title={<Title order={3}>Two-Factor Authentication Required</Title>}
|
||||
size='lg'
|
||||
>
|
||||
<NumberInput
|
||||
placeholder='2FA Code'
|
||||
label='Verify'
|
||||
size='xl'
|
||||
hideControls
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e)}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={disabled}
|
||||
size='lg'
|
||||
fullWidth
|
||||
mt='md'
|
||||
rightIcon={<CheckIcon />}
|
||||
onClick={() => onSubmit(form.values)}
|
||||
>
|
||||
Verify & Login
|
||||
</Button>
|
||||
</Modal>
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<div>
|
||||
<Title size={70} align='center'>
|
||||
@@ -107,7 +162,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
{oauth_providers.map(({ url, name, Icon }, i) => (
|
||||
<Link key={i} href={url} passHref legacyBehavior>
|
||||
<Button size='lg' fullWidth leftIcon={<Icon colorScheme='manage' />} component='a' my='sm'>
|
||||
Login in with {name}
|
||||
Login with {name}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function FilesPage(props) {
|
||||
</Head>
|
||||
|
||||
<Layout props={props}>
|
||||
<Files disableMediaPreview={props.disable_media_preview} />
|
||||
<Files disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function DashboardPage(props) {
|
||||
<title>{props.title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<Dashboard disableMediaPreview={props.disable_media_preview} />
|
||||
<Dashboard disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,10 +2,11 @@ import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import Manage from 'components/pages/Manage';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import type { ServerSideProps } from 'middleware/getServerSideProps';
|
||||
import Head from 'next/head';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
|
||||
export default function ManagePage(props) {
|
||||
export default function ManagePage(props: ServerSideProps) {
|
||||
const { loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
@@ -17,7 +18,11 @@ export default function ManagePage(props) {
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<Manage oauth_providers={props.oauth_providers} oauth_registration={props.oauth_registration} />
|
||||
<Manage
|
||||
oauth_providers={props.oauth_providers}
|
||||
oauth_registration={props.oauth_registration}
|
||||
totp_enabled={props.totp_enabled}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
23
src/pages/dashboard/metadata/[id].tsx
Normal file
23
src/pages/dashboard/metadata/[id].tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import MetadataView from 'components/pages/MetadataView';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Head from 'next/head';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
|
||||
export default function MetadataPage(props) {
|
||||
const { loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{props.title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<MetadataView fileId={props.fileId} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import Upload from 'components/pages/Upload';
|
||||
import File from 'components/pages/Upload/File';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Head from 'next/head';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
@@ -17,7 +17,7 @@ export default function UploadPage(props) {
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<Upload chunks={{ chunks_size: props.chunks_size, max_size: props.max_size }} />
|
||||
<File chunks={{ chunks_size: props.chunks_size, max_size: props.max_size }} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import UploadText from 'components/pages/UploadText';
|
||||
import Text from 'components/pages/Upload/Text';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Head from 'next/head';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
@@ -17,7 +17,7 @@ export default function UploadTextPage(props) {
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<UploadText />
|
||||
<Text />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Image, PrismaClient } from '@prisma/client';
|
||||
import Router from 'find-my-way';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { createServer, IncomingMessage, OutgoingMessage, ServerResponse } from 'http';
|
||||
import next from 'next';
|
||||
@@ -80,19 +81,33 @@ async function start() {
|
||||
const handle = nextServer.getRequestHandler();
|
||||
const router = Router({
|
||||
defaultRoute: (req, res) => {
|
||||
if (config.features.headless) {
|
||||
const url = req.url.toLowerCase();
|
||||
if (!url.startsWith('/api') || url === '/api') return notFound(req, res, nextServer);
|
||||
}
|
||||
|
||||
handle(req, res);
|
||||
},
|
||||
});
|
||||
|
||||
router.on('GET', '/favicon.ico', async (req, res) => {
|
||||
if (!existsSync('./public/favicon.ico')) return notFound(req, res, nextServer);
|
||||
|
||||
const favicon = createReadStream('./public/favicon.ico');
|
||||
res.setHeader('Content-Type', 'image/x-icon');
|
||||
|
||||
favicon.pipe(res);
|
||||
});
|
||||
|
||||
router.on('GET', `${config.urls.route}/:id`, async (req, res, params) => {
|
||||
if (params.id === '') return nextServer.render404(req, res as ServerResponse);
|
||||
if (params.id === '') return notFound(req, res, nextServer);
|
||||
|
||||
const url = await prisma.url.findFirst({
|
||||
where: {
|
||||
OR: [{ id: params.id }, { vanity: params.id }, { invisible: { invis: decodeURI(params.id) } }],
|
||||
},
|
||||
});
|
||||
if (!url) return nextServer.render404(req, res as ServerResponse);
|
||||
if (!url) return notFound(req, res, nextServer);
|
||||
|
||||
const nUrl = await prisma.url.update({
|
||||
where: {
|
||||
@@ -112,36 +127,42 @@ async function start() {
|
||||
|
||||
Logger.get('url').debug(`url deleted due to max views ${JSON.stringify(nUrl)}`);
|
||||
|
||||
return nextServer.render404(req, res as ServerResponse);
|
||||
return notFound(req, res, nextServer);
|
||||
}
|
||||
|
||||
return redirect(res, url.destination);
|
||||
});
|
||||
|
||||
router.on('GET', `${config.uploader.route}/:id`, async (req, res, params) => {
|
||||
if (params.id === '') return nextServer.render404(req, res as ServerResponse);
|
||||
router.on(
|
||||
'GET',
|
||||
config.uploader.route === '/' ? '/:id' : `${config.uploader.route}/:id`,
|
||||
async (req, res, params) => {
|
||||
if (params.id === '') return notFound(req, res, nextServer);
|
||||
else if (params.id === 'dashboard' && !config.features.headless)
|
||||
return nextServer.render(req, res as ServerResponse, '/dashboard');
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }],
|
||||
},
|
||||
});
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [{ file: params.id }, { invisible: { invis: decodeURI(params.id) } }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) return rawFile(req, res, nextServer, params.id);
|
||||
else {
|
||||
const failed = await preFile(image, prisma);
|
||||
if (failed) return nextServer.render404(req, res as ServerResponse);
|
||||
if (!image) return rawFile(req, res, nextServer, params.id);
|
||||
else {
|
||||
const failed = await preFile(image, prisma);
|
||||
if (failed) return notFound(req, res, nextServer);
|
||||
|
||||
if (image.password || image.embed || image.mimetype.startsWith('text/'))
|
||||
redirect(res, `/view/${image.file}`);
|
||||
else fileDb(req, res, nextServer, handle, image);
|
||||
if (image.password || image.embed || image.mimetype.startsWith('text/'))
|
||||
redirect(res, `/view/${image.file}`);
|
||||
else fileDb(req, res, nextServer, handle, image);
|
||||
|
||||
postFile(image, prisma);
|
||||
postFile(image, prisma);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
router.on('GET', '/r/:id', async (req, res, params) => {
|
||||
if (params.id === '') return nextServer.render404(req, res as ServerResponse);
|
||||
if (params.id === '') return notFound(req, res, nextServer);
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
@@ -152,13 +173,17 @@ async function start() {
|
||||
if (!image) await rawFile(req, res, nextServer, params.id);
|
||||
else {
|
||||
const failed = await preFile(image, prisma);
|
||||
if (failed) return nextServer.render404(req, res as ServerResponse);
|
||||
if (failed) return notFound(req, res, nextServer);
|
||||
|
||||
if (image.password) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.statusCode = 403;
|
||||
return res.end(
|
||||
JSON.stringify({ error: "can't view a raw file that has a password", url: `/view/${image.file}` })
|
||||
JSON.stringify({
|
||||
error: "can't view a raw file that has a password",
|
||||
url: `/view/${image.file}`,
|
||||
code: 403,
|
||||
})
|
||||
);
|
||||
} else await rawFile(req, res, nextServer, params.id);
|
||||
}
|
||||
@@ -187,19 +212,27 @@ async function start() {
|
||||
|
||||
http.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
|
||||
logger.info(`started ${dev ? 'development' : 'production'} zipline@${version} server`);
|
||||
logger.info(
|
||||
`started ${dev ? 'development' : 'production'} zipline@${version} server${
|
||||
config.features.headless ? ' (headless)' : ''
|
||||
}`
|
||||
);
|
||||
|
||||
stats(prisma);
|
||||
clearInvites(prisma);
|
||||
|
||||
setInterval(async () => {
|
||||
const { count } = await prisma.invite.deleteMany({
|
||||
where: {
|
||||
used: true,
|
||||
},
|
||||
});
|
||||
setInterval(() => clearInvites(prisma), config.core.invites_interval * 1000);
|
||||
setInterval(() => stats(prisma), config.core.stats_interval * 1000);
|
||||
}
|
||||
|
||||
logger.debug(`deleted ${count} used invites`);
|
||||
}, config.core.invites_interval * 1000);
|
||||
async function notFound(req: IncomingMessage, res: ServerResponse, nextServer: NextServer) {
|
||||
if (config.features.headless) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.end(JSON.stringify({ error: 'not found', url: req.url, code: 404 }));
|
||||
} else {
|
||||
return notFound(req, res, nextServer);
|
||||
}
|
||||
}
|
||||
|
||||
async function preFile(file: Image, prisma: PrismaClient) {
|
||||
@@ -233,7 +266,7 @@ async function postFile(file: Image, prisma: PrismaClient) {
|
||||
|
||||
async function rawFile(req: IncomingMessage, res: OutgoingMessage, nextServer: NextServer, id: string) {
|
||||
const data = await datasource.get(id);
|
||||
if (!data) return nextServer.render404(req, res as ServerResponse);
|
||||
if (!data) return notFound(req, res as ServerResponse, nextServer);
|
||||
|
||||
const mimetype = await guess(extname(id));
|
||||
const size = await datasource.size(id);
|
||||
@@ -242,7 +275,10 @@ async function rawFile(req: IncomingMessage, res: OutgoingMessage, nextServer: N
|
||||
res.setHeader('Content-Length', size);
|
||||
|
||||
data.pipe(res);
|
||||
data.on('error', () => nextServer.render404(req, res as ServerResponse));
|
||||
data.on('error', (e) => {
|
||||
logger.debug(`error while serving raw file ${id}: ${e}`);
|
||||
notFound(req, res as ServerResponse, nextServer);
|
||||
});
|
||||
data.on('end', () => res.end());
|
||||
}
|
||||
|
||||
@@ -257,14 +293,18 @@ async function fileDb(
|
||||
if (Object.keys(exts).includes(ext)) return handle(req, res as ServerResponse);
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return nextServer.render404(req, res as ServerResponse);
|
||||
if (!data) return notFound(req, res as ServerResponse, nextServer);
|
||||
|
||||
const size = await datasource.size(image.file);
|
||||
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.setHeader('Content-Length', size);
|
||||
|
||||
data.pipe(res);
|
||||
data.on('error', () => nextServer.render404(req, res as ServerResponse));
|
||||
data.on('error', (e) => {
|
||||
logger.debug(`error while serving raw file ${image.file}: ${e}`);
|
||||
notFound(req, res as ServerResponse, nextServer);
|
||||
});
|
||||
data.on('end', () => res.end());
|
||||
}
|
||||
|
||||
@@ -277,15 +317,21 @@ async function stats(prisma: PrismaClient) {
|
||||
});
|
||||
|
||||
logger.debug(`stats updated ${JSON.stringify(stats)}`);
|
||||
|
||||
setInterval(async () => {
|
||||
const stats = await getStats(prisma, datasource);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`stats updated ${JSON.stringify(stats)}`);
|
||||
}, config.core.stats_interval * 1000);
|
||||
}
|
||||
|
||||
async function clearInvites(prisma: PrismaClient) {
|
||||
const { count } = await prisma.invite.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
expires_at: { lt: new Date() },
|
||||
},
|
||||
{
|
||||
used: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`deleted ${count} used invites`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user