Compare commits

..

24 Commits

Author SHA1 Message Date
diced
725ce50608 feat(V3.6.2): version 2022-12-01 09:47:09 -08:00
diced
78e884e97e feat: default expiration/ttl (#237) 2022-12-01 09:42:16 -08:00
diced
cb123cb575 fix: formatting 2022-12-01 09:31:29 -08:00
diced
6f3081cb8e feat: headless mode 2022-12-01 09:27:14 -08:00
dicedtomato
231f734fd5 Update README.md 2022-11-28 20:47:24 -08:00
TacticalCoderJay
fce7325a24 fix: add onDelete to all relations (#236) 2022-11-28 20:39:20 -08:00
diced
2bec45411f feat: overhaul upload frontend 2022-11-28 19:58:21 -08:00
diced
577195b578 fix: serve favicon.ico always 2022-11-27 20:06:22 -08:00
diced
a402227c4f fix: custom placeholder 2022-11-27 19:55:14 -08:00
TacticalCoderJay
a75b790654 fix: allow root route & remove swift refs (#235) (#214)
* fix:
 - readd root route for uploads only
 - catch 1 edge case for root route (/dashboard)

* fix: spelt dashboard right

* fix: include the dot for the extension

* fix: remove any possible references of swift

* fix: missed a spot

* Update .env.local.example

Co-authored-by: Jonathan <axis@axis.moe>

* Update .env.local.example

* Update validateConfig.ts

* format

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
Co-authored-by: Jonathan <axis@axis.moe>
2022-11-27 18:20:29 -08:00
TacticalCoderJay
f07cbeac52 fix: small bug & no file ext (#234)
* fix: allow empty file extensions

* fix: Add a button to show non-media files

* fix: Looks better

* Update FilePagation.tsx

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-11-26 16:15:09 -08:00
diced
dcfcce7803 update readme with stuff 2022-11-26 14:33:42 -08:00
diced
659868181d feat: supabase datasource & remove swift 2022-11-25 14:30:18 -08:00
diced
d76e6444e0 fix: dumb cors headers 2022-11-24 14:34:24 -08:00
diced
0dbbf4840c fix: CORS not working because of auth headers 2022-11-24 14:17:46 -08:00
diced
1b6af9fc08 fix: OPTIONS being blocked 2022-11-24 10:35:06 -08:00
karlmanait
8e1541ea56 feat: add configuration for default upload format (#232)
* feat: add configuration for default upload format

* fix: change default back to original
2022-11-22 20:36:49 -08:00
diced
fd9908833a fix: link on files 2022-11-20 00:01:31 -08:00
diced
24f8300b2c fix: delete invites that expired or are used 2022-11-19 17:16:46 -08:00
diced
8d510f5d90 fix: typo 2022-11-19 17:05:15 -08:00
diced
6457680065 feat: exif metadata & remove gps 2022-11-19 13:58:14 -08:00
diced
3175911105 feat: totp 2022-11-17 22:13:23 -08:00
dicedtomato
00f26bdc75 fix: delete suggestion issues, use discussions 2022-11-17 16:04:08 -08:00
Winter
9db95bb772 fix: README spelling errors (#224) 2022-11-17 15:30:49 -08:00
50 changed files with 3543 additions and 1594 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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]

View File

@@ -0,0 +1,5 @@
import { Key } from 'react-feather';
export default function KeyIcon({ ...props }) {
return <Key size={15} {...props} />;
}

View File

@@ -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,
};

View File

@@ -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}
/>
))
) : (

View File

@@ -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>

View File

@@ -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

View File

@@ -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} />
</>
);
}

View 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>
);
}

View File

@@ -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>

View 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} />
)}
</>
);
}

View File

@@ -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>

View 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>
</>
);
}

View 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>
),
});
}

View 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,
];
}

View File

@@ -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>
</>
);
}

View File

@@ -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;
}

View File

@@ -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 = {};

View File

@@ -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')

View File

@@ -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');

View 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));
});
});
}
}

View File

@@ -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);
}
}

View File

@@ -1,4 +1,4 @@
export { Datasource } from './Datasource';
export { Local } from './Local';
export { S3 } from './S3';
export { Swift } from './Swift';
export { Supabase } from './Supabase';

View File

@@ -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;
};

View File

@@ -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}`);
}

View File

@@ -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));
};

View File

@@ -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),

View File

@@ -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
View 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
View 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);
}

View File

@@ -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
View 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,
});

View File

@@ -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) {

View 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,
});

View File

@@ -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 &amp; 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>
))}

View File

@@ -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>
</>
);

View File

@@ -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>
</>
);

View File

@@ -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>
</>
);

View 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>
</>
);
}

View File

@@ -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>
</>
);

View File

@@ -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>
</>
);

View File

@@ -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`);
}

2788
yarn.lock

File diff suppressed because it is too large Load Diff