Compare commits

..

43 Commits

Author SHA1 Message Date
diced ae11a29057 feat(v4.6.0): version 2026-05-11 16:24:14 -07:00
diced 4776d9e85f fix: totp 2fa QOL #1073 2026-05-11 16:18:37 -07:00
diced 41b63e6f25 fix: #1081 2026-05-11 16:07:41 -07:00
diced 24b332c23e fix: build errors 2026-05-09 17:55:05 -07:00
diced 3fd9154e57 fix: #1072 2026-05-09 17:54:24 -07:00
diced 3f71769ec6 fix: #1069 2026-05-09 17:18:14 -07:00
diced a99b0f4f1d refactor: use access tokens for file/url passwords
no longer using cookies, since they are buggy and weird with caching
using a access token that "expires" in 10 minutes
2026-05-04 18:21:53 -07:00
diced 15f5279ddb fix: perf improvements 2026-04-27 17:51:00 -07:00
diced 87a2dfbda6 fix: thin text files 2026-04-27 16:47:57 -07:00
diced c7d2b3010f fix: add tags/folder editing to new viewer 2026-04-27 16:47:44 -07:00
diced 5119806147 fix: build errors 2026-04-25 19:10:14 -07:00
diced 33104ce1be fix: rework passwd protected file logic 2026-04-25 19:07:39 -07:00
diced eeb1c51fb2 fix: build errors 2026-04-24 22:11:17 -07:00
diced 756dee6bba fix: impl serverside pagination folders (#1052) 2026-04-24 22:06:46 -07:00
Tomasz Kołodziej a0907e8791 fix: return 404 status on not-found SPA fallback (#1061) (#1063)
The non-API branch of setNotFoundHandler served the Next.js index shell
without setting the status code, so clients saw HTTP 200 with a Not-Found
UI. This broke monitoring, ingress proxy_intercept_errors rules,
crawlers, and curl -f. Set status 404 explicitly before serving the
index.

Closes #1061

Co-authored-by: dicedtomato <git@diced.sh>
2026-04-23 23:29:10 -07:00
diced 5a58abeb51 fix: #1062 2026-04-23 23:26:35 -07:00
diced 72d8c693c7 chore: update packages 2026-04-19 21:58:06 -07:00
diced 7caf314ce1 fix: session serialization errors 2026-04-19 21:49:19 -07:00
diced 677927b4a6 fix: blob urls not persisting 2026-04-18 22:43:32 -07:00
diced ac0b718f77 fix: date format #1056 2026-04-18 22:42:16 -07:00
diced db3a1b88ad fix: passkey fields validation (#1047) 2026-04-18 22:35:09 -07:00
diced a97cf32682 feat: make 'fullscreen' viewer default 2026-04-18 16:24:41 -07:00
diced 7e2b4ed1bb fix: build errors 2026-04-18 16:24:14 -07:00
diced a7fdf5afed fix: fix issues and polish new fullscreen viewer 2026-04-18 16:08:42 -07:00
diced db8adcc768 fix: #384 again 2026-04-18 16:07:59 -07:00
diced 135cf1982a fix: build errors 2026-04-15 23:23:56 -07:00
diced 9925300e9d feat: add details drawer for fullscreen viewer 2026-04-15 23:19:56 -07:00
diced 3bf125b4b4 fix: leftover import 2026-04-15 00:16:47 -07:00
diced dc9abe4383 feat: new file viewer 2026-04-15 00:15:49 -07:00
diced 1ccbc878f8 fix: add arrow key control (#1049) 2026-04-14 17:39:10 -07:00
diced aa43f66570 fix: add missing metrics interval 2026-04-13 21:50:52 -07:00
diced 7e3bba5e55 feat: overhaul oauth + PKCE for OIDC
refactored a lot of oauth stuff, so there may be bugs
2026-04-13 21:33:11 -07:00
diced 82e1fe4824 fix: naming 2026-04-12 22:51:41 -07:00
diced 818d3f5518 refactor: clean up main file
split into different files for maintainability
2026-04-11 22:11:13 -07:00
diced 23c131f45a feat: add unix sockets binding support 2026-04-11 21:28:22 -07:00
diced 3c5fd8effe feat: file nav buttons (#1046)
<- and -> on file modal
2026-04-11 17:49:12 -07:00
diced 377e3dc73d fix: throw errors on uploads 2026-04-10 22:44:59 -07:00
diced f75457da1c fix: various md rendering errors 2026-04-10 22:12:36 -07:00
diced d6b0ba3b16 fix: better error handling for uploading 2026-04-09 14:51:43 -07:00
diced 1a1bc46667 fix: delete old cached views 2026-04-09 14:51:27 -07:00
diced eb1c39933a fix: #1040 2026-04-08 18:26:57 -07:00
diced b070dbf432 fix: build errors 2026-04-07 21:49:43 -07:00
diced 8af5ad05d6 fix: add register link on login page
other minor fixes as well incl.
2026-04-07 21:45:03 -07:00
103 changed files with 4958 additions and 4532 deletions
+30 -31
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.5.3",
"version": "4.6.0",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
@@ -22,27 +22,27 @@
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1025.0",
"@aws-sdk/lib-storage": "3.1025.0",
"@aws-sdk/client-s3": "3.1032.0",
"@aws-sdk/lib-storage": "3.1032.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^9.0.0",
"@fastify/static": "^9.1.1",
"@fastify/swagger": "^9.7.0",
"@mantine/charts": "^9.0.1",
"@mantine/code-highlight": "^9.0.1",
"@mantine/core": "^9.0.1",
"@mantine/dates": "^9.0.1",
"@mantine/dropzone": "^9.0.1",
"@mantine/form": "^9.0.1",
"@mantine/hooks": "^9.0.1",
"@mantine/modals": "^9.0.1",
"@mantine/notifications": "^9.0.1",
"@mantine/charts": "^9.0.2",
"@mantine/code-highlight": "^9.0.2",
"@mantine/core": "^9.0.2",
"@mantine/dates": "^9.0.2",
"@mantine/dropzone": "^9.0.2",
"@mantine/form": "^9.0.2",
"@mantine/hooks": "^9.0.2",
"@mantine/modals": "^9.0.2",
"@mantine/notifications": "^9.0.2",
"@prisma/adapter-pg": "6.13.0",
"@prisma/client": "6.13.0",
"@prisma/engines": "6.13.0",
@@ -50,7 +50,7 @@
"@prisma/migrate": "6.13.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@smithy/node-http-handler": "^4.5.2",
"@smithy/node-http-handler": "^4.5.3",
"@tabler/icons-react": "^3.41.1",
"archiver": "^7.0.1",
"argon2": "^0.44.0",
@@ -63,32 +63,31 @@
"cross-env": "^10.1.0",
"dayjs": "^1.11.20",
"detect-browser": "^5.3.0",
"devalue": "^5.7.0",
"devalue": "^5.7.1",
"fast-glob": "^3.3.3",
"fastify": "^5.8.4",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"fastify-type-provider-zod": "^6.1.0",
"fluent-ffmpeg": "^2.1.3",
"he": "^1.2.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^3.7.1",
"isomorphic-dompurify": "^3.9.0",
"katex": "^0.16.45",
"mantine-datatable": "^8.3.13",
"marked-react": "^4.0.0",
"ms": "^2.1.3",
"multer": "2.1.1",
"otplib": "^13.4.0",
"prisma": "6.13.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.4",
"remark-gfm": "^4.0.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"react-virtuoso": "^4.18.5",
"sharp": "^0.34.5",
"swr": "^2.4.1",
"vite": "^8.0.5",
"vite": "^8.0.9",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
@@ -105,24 +104,24 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-unused-imports": "^4.3.0",
"postcss": "^8.5.8",
"postcss": "^8.5.10",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"prettier": "^3.8.3",
"sass": "^1.98.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.1",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.0"
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.2"
},
"engines": {
"node": ">=22"
+1331 -2157
View File
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -33,7 +33,7 @@ import {
IconCircleKeyFilled,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { eitherTrue } from '@/lib/primitive';
@@ -43,7 +43,11 @@ export default function Login() {
const query = new URLSearchParams(location.search);
const navigate = useNavigate();
const { user, mutate } = useLogin();
const { user, mutate } = useLogin({
swrConfig: {
shouldRetryOnError: false,
},
});
const isHttps = window.location.protocol === 'https:';
const webClient = JSON.stringify(getWebClient());
@@ -124,6 +128,12 @@ export default function Login() {
}
};
const handleTotpChange = async (val: string) => {
setTotp('pin', val);
if (val.length === 6) await handleLoginSubmit(form.values, val);
};
if (configLoading || !config) return <LoadingOverlay visible />;
if (configError) return <GenericError title='Error' message='Config load failed' details={configError} />;
@@ -135,7 +145,7 @@ export default function Login() {
<TotpModal
state={totp}
onPinChange={(val) => setTotp('pin', val)}
onPinChange={(val) => handleTotpChange(val)}
onVerify={() => handleLoginSubmit(form.values, totp.pin)}
onCancel={() => {
setTotp('open', false);
@@ -212,6 +222,7 @@ export default function Login() {
config.oauthEnabled.github,
config.oauthEnabled.google,
config.oauthEnabled.oidc,
config.features.userRegistration,
) && (
<>
<Divider label='or' />
@@ -243,6 +254,15 @@ export default function Login() {
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
)}
</Group>
{config.features.userRegistration && (
<Text ta='center' mt='md'>
Don&apos;t have an account?{' '}
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
Register
</Anchor>
</Text>
)}
</>
)}
</Stack>
+10 -16
View File
@@ -1,5 +1,6 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import useUser from '@/lib/client/hooks/useUser';
import { useTitle } from '@/lib/client/hooks/useTitle';
import {
Button,
@@ -18,8 +19,8 @@ import {
import { useForm } from '@mantine/form';
import { notifications, showNotification } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useEffect } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { getWebClient } from '@/lib/api/detect';
@@ -31,8 +32,6 @@ export function Component() {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const {
data: config,
error: configError,
@@ -59,6 +58,8 @@ export function Component() {
},
);
const { user, loading: userLoading } = useUser();
const form = useForm({
initialValues: {
username: '',
@@ -74,17 +75,6 @@ export function Component() {
}),
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
navigate('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!config) return;
@@ -138,7 +128,11 @@ export function Component() {
}
};
if (loading || configLoading) return <LoadingOverlay visible />;
if (userLoading || configLoading) return <LoadingOverlay visible />;
if (user) {
return <Navigate to='/dashboard' replace />;
}
if (!config || configError) {
return (
+66 -26
View File
@@ -1,6 +1,8 @@
import { useApiPagination } from '@/components/pages/files/useApiPagination';
import { type Response } from '@/lib/api/response';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { Folder } from '@/lib/db/models/folder';
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
import {
@@ -19,18 +21,26 @@ import {
Title,
} from '@mantine/core';
import { IconFolder, IconUpload } from '@tabler/icons-react';
import { lazy, Suspense, useMemo, useState } from 'react';
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
import { lazy, Suspense, useEffect, useMemo } from 'react';
import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
export async function loader({ params, request }: { params: Params<string>; request: Request }) {
const url = new URL(request.url);
const page = url.searchParams.get('page') ?? '1';
const perpage = url.searchParams.get('perpage') ?? '15';
const res = await fetch(
`/api/server/folder/${params.id}?page=${encodeURIComponent(page)}&perpage=${encodeURIComponent(perpage)}`,
);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
initial: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
@@ -64,10 +74,30 @@ function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { initial } = useLoaderData<typeof loader>();
const navigate = useNavigate();
useTitle(folder.name);
const [, setSearchParams] = useSearchParams();
const [page, setPage] = useQueryState('page', 1);
const [perpage] = useQueryState('perpage', 15);
const { data, isLoading } = useApiPagination<Response['/api/server/folder/[id]']>(
{
route: `/api/server/folder/${initial.folder.id}`,
page,
perpage,
sort: 'createdAt',
order: 'desc',
},
{ fallbackData: initial, keepPreviousData: true, revalidateOnFocus: false },
);
const folder = data?.folder ?? initial.folder;
const files = data?.page ?? [];
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 0;
useTitle(folder.name ?? 'Folder');
const buildBreadcrumbs = () => {
const items: FolderBreadcrumb[] = [];
@@ -85,25 +115,30 @@ export function Component() {
const breadcrumbs = buildBreadcrumbs();
const children = (folder.children ?? []) as Partial<Folder>[];
const from = totalRecords === 0 ? 0 : (page - 1) * perpage + 1;
const to = Math.min(page * perpage, totalRecords);
const [perpage, setPerpage] = useState(15);
const [page, setPage] = useQueryState('page', 1);
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (files.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => files.map((file) => file.id), [files]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, folder.files?.length ?? 0);
const totalRecords = folder.files?.length ?? 0;
const cachedPages = Math.ceil(totalRecords / perpage);
const visible = useMemo(() => {
if (!folder.files) return [];
const start = (page - 1) * perpage;
return folder.files.slice(start, start + perpage);
}, [folder.files, page, perpage]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<Container my='lg'>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => setCurrent(open ? (currentFile?.id ?? null) : null)}
file={currentFile}
reduce
sequenced
/>
{breadcrumbs.length > 1 && (
<Breadcrumbs mb='md'>
{breadcrumbs.map((item, index) => (
@@ -152,7 +187,7 @@ export function Component() {
</>
)}
{(visible.length ?? 0) > 0 && (
{(files.length ?? 0) > 0 && (
<>
<Title order={3} mt='md' mb='sm'>
Files
@@ -165,16 +200,16 @@ export function Component() {
}}
spacing='md'
>
{visible.map((file: any) => (
{files.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
<DashboardFile file={file} reduce onOpen={(fileId) => setCurrent(fileId)} />
</Suspense>
))}
</SimpleGrid>
</>
)}
{children.length === 0 && (folder.files?.length ?? 0) === 0 && (
{children.length === 0 && totalRecords === 0 && (
<Text c='dimmed' mt='md'>
This folder is empty.
</Text>
@@ -188,12 +223,16 @@ export function Component() {
value={perpage.toString()}
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
onChange={(value) => {
setPerpage(Number(value));
setPage(1);
setSearchParams((prev) => {
prev.set('perpage', value ?? '15');
prev.set('page', '1');
return prev;
});
}}
w={80}
size='xs'
variant='filled'
disabled={isLoading}
/>
<Pagination
@@ -203,6 +242,7 @@ export function Component() {
size='sm'
withControls
withEdges
disabled={isLoading}
/>
</Group>
</Group>
+4 -1
View File
@@ -11,8 +11,11 @@ export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) throw data('Folder not found', { status: 404 });
const d = (await res.json()) as Response['/api/server/folder/[id]'];
if (!d.folder) throw data('Folder not found', { status: 404 });
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
folder: d.folder,
};
}
+14 -20
View File
@@ -1,5 +1,7 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { useTitle } from '@/lib/client/hooks/useTitle';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
@@ -8,7 +10,6 @@ import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
@@ -24,9 +25,7 @@ import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/ic
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
import { useTitle } from '@/lib/client/hooks/useTitle';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
@@ -34,7 +33,7 @@ type SsrData = {
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
token?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
@@ -43,7 +42,7 @@ export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
const { file, password, code, user, host, metrics, filesRoute, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -51,7 +50,7 @@ export default function ViewFileId() {
useTitle(file.originalName ?? file.name ?? 'View File');
return password && !pw ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -64,7 +63,8 @@ export default function ViewFileId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/${file.name}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
@@ -105,7 +105,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -143,15 +143,9 @@ export default function ViewFileId() {
</Paper>
</Collapse>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
<Center m='sm'>
<DashboardFileType file={file as unknown as File} token={token} show code={code} fullscreen />
</Center>
</>
) : (
<>
@@ -201,7 +195,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}${token ? `?token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
@@ -212,7 +206,7 @@ export default function ViewFileId() {
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`}
to={`/raw/${file.name}?download=true${token ? `&token=${encodeURIComponent(token)}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
@@ -221,7 +215,7 @@ export default function ViewFileId() {
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
<DashboardFileType allowZoom file={file as unknown as File} token={token} show />
{user?.view!.content && (
<Typography>
+5 -3
View File
@@ -6,10 +6,11 @@ export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
token?: string | null;
}>();
if (!data) return null;
const { url, password } = data;
const { url, password, token } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
@@ -18,7 +19,7 @@ export default function ViewUrlId() {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
return password && !token ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
@@ -31,7 +32,8 @@ export default function ViewUrlId() {
});
if (res.ok) {
window.location.reload();
const json = (await res.json()) as { token: string };
window.location.replace(`/view/url/${url.id}?token=${encodeURIComponent(json.token)}`);
} else {
setPasswordError('Invalid password');
}
+13 -21
View File
@@ -1,13 +1,11 @@
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { verifyAccessToken } from '@/lib/accessToken';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
import { FastifyRequest } from 'fastify';
import { createRoutes } from './routes';
export async function render(
{
@@ -17,13 +15,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -52,31 +48,27 @@ export async function render(
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const token = req.query.token;
const valid = token && urlEntry.password ? verifyAccessToken(token, 'url', urlEntry.id) : false;
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
token: valid ? token : null,
};
delete (data.url as any).password;
const routes = createRoutes(themes, defaultTheme);
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
if (!valid) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
+11 -14
View File
@@ -5,24 +5,23 @@ import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { verifyAccessToken } from '@/lib/accessToken';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import { stripHtml } from '@/lib/stripHtml';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
import { stripHtml } from '@/lib/stripHtml';
export const getFile = async (id: string) =>
prisma.file.findFirst({
@@ -44,11 +43,11 @@ export async function render(
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
req: FastifyRequest<{ Params: { id: string }; Querystring: { token?: string } }>;
},
url: string,
) {
const id = url.split('/').pop();
const id = req.params?.id ?? null;
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
@@ -94,17 +93,15 @@ export async function render(
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const token = req.query.token;
const valid = token && file.password ? verifyAccessToken(token, 'file', file.id) : false;
const hasPassword = !!file.password;
delete (file as any).password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
console.log('File is password protected');
if (!valid) {
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
@@ -141,7 +138,7 @@ export async function render(
const data = {
file,
password: hasPassword,
pw: pw || null,
token: valid ? token : null,
code,
user,
host,
@@ -0,0 +1,22 @@
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File } from '@/lib/db/models/file';
import FileModal from './FileModal';
import FileViewer from './FileViewer';
export default function DashboardFileModal(props: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const fileModal = useSettingsStore((state) => state.settings.fileViewer);
if (fileModal === 'default') {
return <FileModal {...props} />;
}
return <FileViewer {...props} />;
}
@@ -120,7 +120,7 @@ export default function EditFileDetailsModal({
};
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Modal zIndex={400} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
+106 -5
View File
@@ -2,14 +2,16 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useSettingsStore } from '@/lib/client/store/settings';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
@@ -31,6 +33,8 @@ import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
@@ -50,8 +54,9 @@ import {
IconUpload,
IconUserQuestion,
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
@@ -73,15 +78,16 @@ function ActionButton({
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
}) {
} & ActionIconProps) {
return (
<Tooltip label={tooltip}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick}>
<ActionIcon variant='filled' color={color ?? 'gray'} onClick={onClick} {...props}>
<Icon size='1rem' />
</ActionIcon>
</Tooltip>
@@ -94,15 +100,18 @@ export default function FileModal({
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const [editFileOpen, setEditFileOpen] = useState(false);
@@ -181,6 +190,34 @@ export default function FileModal({
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) {
return [state.goPrev, state.goNext, false, false];
}
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open || !sequenced) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft' && hasPrev) {
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
goNext();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext]);
return (
<>
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file!} />
@@ -431,6 +468,70 @@ export default function FileModal({
<></>
)}
</Modal>
{open && sequenced && fileNavButtons && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 0.75rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
size='lg'
/>
</>
)}
</>
);
}
@@ -0,0 +1,640 @@
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
import TagPill from '@/components/pages/files/tags/TagPill';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { useSettingsStore } from '@/lib/client/store/settings';
import { File } from '@/lib/db/models/file';
import { Tag } from '@/lib/db/models/tag';
import { fetchApi } from '@/lib/fetchApi';
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
import {
ActionIcon,
ActionIconProps,
Box,
Button,
Checkbox,
Combobox,
Drawer,
Group,
Input,
InputBase,
Paper,
Pill,
PillsInput,
Stack,
Text,
Title,
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import {
Icon,
IconBombFilled,
IconChevronLeft,
IconChevronRight,
IconClipboardTypography,
IconCopy,
IconDeviceSdCard,
IconDownload,
IconExternalLink,
IconEyeFilled,
IconFileInfo,
IconFolderMinus,
IconInfoCircle,
IconPencil,
IconRefresh,
IconStar,
IconStarFilled,
IconTags,
IconTagsOff,
IconTextRecognition,
IconTrashFilled,
IconUpload,
IconUserQuestion,
IconX,
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
import DashboardFileType from '../DashboardFileType';
import {
addToFolder,
copyFile,
createFolderAndAdd,
deleteFile,
downloadFile,
favoriteFile,
mutateFiles,
removeFromFolder,
viewFile,
} from '../actions';
import EditFileDetailsModal from './EditFileDetailsModal';
import FileStat from './FileStat';
function ActionButton({
Icon,
onClick,
tooltip,
color,
...props
}: {
Icon: Icon;
onClick: () => void;
tooltip: string;
color?: string;
} & ActionIconProps) {
return (
<Tooltip label={tooltip} zIndex='200'>
<ActionIcon
size='xl'
variant='subtle'
bd='1px solid var(--mantine-color-dark-4)'
color={color ?? 'gray'}
onClick={onClick}
{...props}
>
<Icon size='1.15rem' />
</ActionIcon>
</Tooltip>
);
}
export default function FileViewer({
open,
setOpen,
file,
reduce,
user,
sequenced,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
user?: string;
sequenced?: boolean;
}) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
const { data: folders } = useFolders(user);
const folderOptions = useMemo(() => {
if (!folders) return [];
return buildFolderHierarchy(folders);
}, [folders]);
const folderCombobox = useCombobox();
const [search, setSearch] = useState('');
const handleAdd = async (value: string) => {
if (value === '$create') {
await createFolderAndAdd(file!, search.trim());
} else {
await addToFolder(file!, value);
}
};
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>(
user ? `/api/users/${user}/tags` : '/api/user/tags',
);
const tagsCombobox = useCombobox();
const [value, setValue] = useState<string[]>(() => file?.tags?.map((x) => x.id) ?? []);
const handleValueSelect = (val: string) => {
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
};
const handleValueRemove = (val: string) => {
setValue((current) => current.filter((v) => v !== val));
};
const handleTagsUpdate = async () => {
if (value.length === file?.tags?.length && value.every((v) => file?.tags?.map((x) => x.id).includes(v))) {
return;
}
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/files/${file!.id}`,
'PATCH',
{
tags: value,
},
);
if (error) {
showNotification({
title: 'Failed to save tags',
message: error.error,
color: 'red',
icon: <IconTagsOff size='1rem' />,
});
} else {
showNotification({
title: 'Saved tags',
message: `Saved ${data!.tags!.length} tags for file ${data!.name}`,
color: 'green',
icon: <IconTags size='1rem' />,
});
}
mutateFiles();
mutate('/api/user/tags');
};
const triggerSave = async () => {
tagsCombobox.closeDropdown();
handleTagsUpdate();
};
const values = value.map((id) => <TagPill key={id} tag={tags?.find((t) => t.id === id) || null} />);
const [editFileOpen, setEditFileOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const [goPrev, goNext, hasPrev, hasNext] = useFileNavStore(
useShallow((state) => {
if (!state.current) return [state.goPrev, state.goNext, false, false];
const idx = state.ids.indexOf(state.current);
return [state.goPrev, state.goNext, idx > 0, idx >= 0 && idx < state.ids.length - 1];
}),
);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
return;
}
if (!sequenced) return;
if (event.key === 'ArrowLeft' && hasPrev) {
event.preventDefault();
goPrev();
} else if (event.key === 'ArrowRight' && hasNext) {
event.preventDefault();
goNext();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open, sequenced, hasPrev, hasNext, goPrev, goNext, setOpen]);
const headerActionGroup = file ? (
<ActionIcon.Group>
{!reduce && (
<>
<ActionButton
Icon={IconPencil}
onClick={() => setEditFileOpen(true)}
tooltip='Edit file details'
color='orange'
/>
<ActionButton
Icon={IconTrashFilled}
onClick={() => deleteFile(warnDeletion, file, setOpen)}
tooltip='Delete file'
color='red'
/>
<ActionButton
Icon={file.favorite ? IconStarFilled : IconStar}
onClick={() => favoriteFile(file)}
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
color={file.favorite ? 'gray' : 'yellow'}
/>
</>
)}
<ActionButton
Icon={IconInfoCircle}
onClick={() => setInfoOpen((v) => !v)}
tooltip={infoOpen ? 'Hide details' : 'Show details'}
color={infoOpen ? 'cyan' : 'gray'}
/>
<ActionButton
Icon={IconExternalLink}
onClick={() => viewFile(file)}
tooltip='Open in new tab'
color='blue'
/>
<ActionButton
Icon={IconClipboardTypography}
onClick={() => copyFile(file, clipboard, true)}
tooltip='Copy raw file link'
/>
<ActionButton Icon={IconCopy} onClick={() => copyFile(file, clipboard)} tooltip='Copy file link' />
<ActionButton Icon={IconDownload} onClick={() => downloadFile(file)} tooltip='Download' />
</ActionIcon.Group>
) : null;
return (
<>
{file && (
<EditFileDetailsModal open={editFileOpen} onClose={() => setEditFileOpen(false)} file={file} />
)}
<Drawer
opened={infoOpen}
onClose={() => setInfoOpen(false)}
position='right'
title={<Title order={2}>Details</Title>}
radius='md'
offset={20}
overlayProps={{ blur: 6 }}
>
{file && (
<Stack gap='md'>
<FileStat Icon={IconFileInfo} title='Type' value={file.type} />
<FileStat Icon={IconDeviceSdCard} title='Size' value={bytes(file.size)} />
<FileStat
Icon={IconUpload}
title='Created at'
value={new Date(file.createdAt).toLocaleString()}
/>
<FileStat
Icon={IconRefresh}
title='Updated at'
value={new Date(file.updatedAt).toLocaleString()}
/>
{file.deletesAt && !reduce && (
<FileStat
Icon={IconBombFilled}
title='Deletes at'
value={new Date(file.deletesAt).toLocaleString()}
/>
)}
<FileStat
Icon={IconEyeFilled}
title='Views'
value={file.maxViews ? `${file.views} / ${file.maxViews}` : file.views}
/>
{file.originalName && (
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
)}
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
{!reduce && (
<>
<Box>
<Title order={4} mb='xs'>
Tags
</Title>
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
<Combobox.DropdownTarget>
<PillsInput
onBlur={() => triggerSave()}
pointer
onClick={() => tagsCombobox.openDropdown()}
>
<Pill.Group>
{values.length > 0 ? (
values
) : (
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
)}
<Combobox.EventsTarget>
<PillsInput.Field
type='hidden'
onFocus={() => tagsCombobox.openDropdown()}
onBlur={() => tagsCombobox.closeDropdown()}
onKeyDown={(event) => {
if (
event.key === 'Backspace' &&
value.length > 0 &&
event.currentTarget.value === ''
) {
event.preventDefault();
handleValueRemove(value[value.length - 1]);
}
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>
{tags?.length ? (
tags.map((tag) => (
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
<Group gap='sm'>
<Checkbox
checked={value.includes(tag.id)}
onChange={() => {}}
aria-hidden
tabIndex={-1}
style={{ pointerEvents: 'none' }}
/>
<TagPill tag={tag} />
</Group>
</Combobox.Option>
))
) : (
<Combobox.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Box>
<Box>
<Title order={4} mb='xs'>
Folder
</Title>
{file.folderId ? (
<Button
color='red'
leftSection={<IconFolderMinus size='1rem' />}
onClick={() => removeFromFolder(file)}
fullWidth
>
Remove from folder &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
value={search}
onChange={(event) => {
folderCombobox.openDropdown();
folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
}}
onClick={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onFocus={() => {
folderCombobox.openDropdown();
setSearch('');
}}
onBlur={() => {
folderCombobox.closeDropdown();
setSearch('');
}}
placeholder='Add to folder...'
rightSectionPointerEvents='none'
/>
</Combobox.Target>
<Combobox.Dropdown>
{folders?.length === 0 && (
<Combobox.Empty>
You have no folders. Start typing to create a new folder for this file.
</Combobox.Empty>
)}
<FolderComboboxOptions
folderOptions={folderOptions}
searchValue={search}
additionalOptions={
!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 ? (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
) : null
}
/>
</Combobox.Dropdown>
</Combobox>
)}
</Box>
</>
)}
</Stack>
)}
</Drawer>
<Box
onClick={() => setOpen(false)}
style={{
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
flexDirection: 'column',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 220ms cubic-bezier(0.33, 1, 0.68, 1)',
willChange: 'opacity',
}}
>
<Paper m={0} p={0} withBorder bdrs={0} style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Stack gap='sm' px='lg' py='sm' onClick={(e) => e.stopPropagation()}>
<Group justify='space-between' align='center' gap='sm' wrap='nowrap' visibleFrom='sm'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<Group gap='sm' wrap='nowrap' style={{ flexShrink: 0 }}>
{headerActionGroup}
<ActionButton Icon={IconX} tooltip='Close' onClick={() => setOpen(false)} />
</Group>
</Group>
<Stack gap='sm' hiddenFrom='sm'>
<Group justify='space-between' align='flex-start' gap='sm' wrap='nowrap'>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size='lg' fw={600} lineClamp={1} c='white'>
{file?.name ?? ''}
</Text>
{file && (
<Text size='sm' c='dimmed' lineClamp={1}>
{file.type} ({bytes(file.size)})
</Text>
)}
</Box>
<ActionButton
Icon={IconX}
tooltip='Close'
onClick={() => setOpen(false)}
style={{ flexShrink: 0 }}
/>
</Group>
<Group gap={0} wrap='nowrap'>
{headerActionGroup}
</Group>
</Stack>
</Stack>
</Paper>
<Box
ref={setScrollParent}
style={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'stretch',
justifyContent: 'flex-start',
paddingTop: '1rem',
paddingBottom: '1rem',
marginLeft: '1rem',
marginRight: '1rem',
overflow: 'auto',
position: 'relative',
overscrollBehavior: 'contain',
}}
>
{file ? (
<Box
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
width: '100%',
height: 'fit-content',
minWidth: 0,
minHeight: 0,
overflow: 'visible',
paddingLeft: '4rem',
paddingRight: '4rem',
}}
>
<DashboardFileType
key={file.id}
file={file}
show
fullscreen
allowZoom={false}
scrollParent={scrollParent}
/>
{open && sequenced && fileNavButtons && file && (
<>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
hiddenFrom='sm'
style={{
position: 'fixed',
left: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
hiddenFrom='sm'
style={{
position: 'fixed',
right: '0.75rem',
top: 'calc(env(safe-area-inset-top, 0px) + 10rem)',
zIndex: 1000,
}}
size='md'
/>
<ActionButton
Icon={IconChevronLeft}
tooltip='Previous file'
onClick={() => goPrev()}
disabled={!hasPrev}
visibleFrom='sm'
style={{
position: 'fixed',
left: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
<ActionButton
Icon={IconChevronRight}
tooltip='Next file'
onClick={() => goNext()}
disabled={!hasNext}
visibleFrom='sm'
style={{
position: 'fixed',
right: '1rem',
top: '50%',
zIndex: 1000,
}}
variant='filled'
/>
</>
)}
</Box>
) : null}
</Box>
</Box>
</>
);
}
+21 -4
View File
@@ -2,17 +2,34 @@ import type { File } from '@/lib/db/models/file';
import { Card } from '@mantine/core';
import { useState } from 'react';
import DashboardFileType from '../DashboardFileType';
import FileModal from './FileModal';
import DashboardFileModal from './DashboardFileModal';
import styles from './index.module.css';
export default function DashboardFile({ file, reduce, id }: { file: File; reduce?: boolean; id?: string }) {
export default function DashboardFile({
file,
reduce,
id,
onOpen,
}: {
file: File;
reduce?: boolean;
id?: string;
onOpen?: (fileId: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<>
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />
<Card shadow='md' radius='md' p={0} onClick={() => setOpen(true)} className={styles.file}>
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
<Card
shadow='md'
radius='md'
p={0}
onClick={() => (onOpen ? onOpen(file.id) : setOpen(true))}
className={styles.file}
>
<DashboardFileType key={file.id} file={file} />
</Card>
</>
-339
View File
@@ -1,339 +0,0 @@
import { useSettingsStore } from '@/lib/client/store/settings';
import { useUserStore } from '@/lib/client/store/user';
import type { File as DbFile } from '@/lib/db/models/file';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useCallback, useEffect, useState } from 'react';
import Asciinema from '../render/Asciinema';
import Pdf from '../render/Pdf';
import Render from '../render/Render';
import { renderMode } from '../render/renderMode';
import fileIcon from './fileIcon';
const MAX_BYTES = 1 * 1024 * 1024;
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
function appendPassword(url: string, password?: string | null) {
return `${url}${password ? `?pw=${encodeURIComponent(password)}` : ''}`;
}
function isDbFile(file: DbFile | File): file is DbFile {
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
}
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
);
}
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
);
}
function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(5px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</div>
);
}
export default function DashboardFileType({
file,
show,
password,
code,
allowZoom,
}: {
file: DbFile | File;
show?: boolean;
password?: string | null;
code?: boolean;
allowZoom?: boolean;
}) {
const user = useUserStore((state) => state.user);
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const dbFile = isDbFile(file);
const fileRoute = dbFile ? (user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`) : '';
const thumbnailRoute = dbFile
? file.thumbnail?.path
? user
? `/api/user/files/${file.thumbnail.path}/raw`
: `/raw/${file.thumbnail.path}`
: null
: null;
const dbFileUrl = dbFile ? appendPassword(fileRoute, password) : '';
const [blobUrl, setBlobUrl] = useState('');
useEffect(() => {
if (dbFile) return setBlobUrl('');
const objectUrl = URL.createObjectURL(file);
setBlobUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [dbFile, file]);
const fileUrl = dbFile ? dbFileUrl : blobUrl;
const extension = file.name.split('.').pop() || '';
const renderIn = renderMode(extension);
const type = code ? 'text' : file.type.split('/')[0];
const [fileContent, setFileContent] = useState('');
const [open, setOpen] = useState(false);
const getText = useCallback(async () => {
try {
if (!dbFile) {
const reader = new FileReader();
reader.onload = () => {
const content = reader.result as string;
if (content.length > MAX_BYTES) {
setFileContent(content.slice(0, MAX_BYTES) + FILE_BIG);
} else {
setFileContent(content);
}
};
reader.readAsText(file);
return;
}
if (file.size > MAX_BYTES) {
const res = await fetch(fileUrl, {
headers: {
Range: `bytes=0-${MAX_BYTES}`,
},
});
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text + FILE_BIG);
return;
}
const res = await fetch(fileUrl);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(text);
} catch {
setFileContent('Error loading file.');
}
}, [dbFile, file, fileUrl]);
useEffect(() => {
if (type === 'text') getText();
}, [type, getText]);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [open]);
if (disableMediaPreview && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (dbFile && file.password === true && !show)
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
if (dbFile && file.password === true && show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(appendPassword(`/view/${file.name}`, password))}
/>
</Paper>
);
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
switch (true) {
case type === 'video':
if (!fileUrl) return <Loader />;
return show ? (
<video
width='100%'
autoPlay
muted
controls
src={fileUrl}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : thumbnailRoute ? (
<Box pos='relative'>
<MantineImage src={thumbnailRoute} alt={file.name || 'Video thumbnail'} />
<Center
pos='absolute'
h='100%'
top='50%'
left='50%'
style={{
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
) : (
<Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'image':
if (!fileUrl) return <Loader />;
return show ? (
<Center>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
maxWidth: '70vw',
maxHeight: '70vw',
}}
onClick={() => allowZoom && setOpen(true)}
/>
{allowZoom && open && (
<FileZoomModal setOpen={setOpen}>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</Center>
) : (
<MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />
);
case type === 'audio':
if (!fileUrl) return <Loader />;
return show ? (
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
case type === 'text':
return show ? (
fileContent.trim() === '' ? (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
) : (
<Render mode={renderIn} language={extension} code={fileContent} />
)
) : (
<Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />
);
case isAsciicast === true:
if (!fileUrl) return <Loader />;
return show ? (
<Asciinema src={fileUrl} />
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
case file.type === 'application/pdf':
if (!fileUrl) return <Loader />;
return show ? (
<Pdf src={fileUrl} />
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
default:
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
if (show)
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(fileUrl)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
return <IconFileUnknown size={48} />;
}
}
@@ -0,0 +1,30 @@
import { Box } from '@mantine/core';
export default function FileZoomModal({
setOpen,
children,
}: {
setOpen: (open: boolean) => void;
children: React.ReactNode;
}) {
return (
<Box
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(calc(0.375rem * var(--mantine-scale)))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
}}
onClick={() => setOpen(false)}
>
{children}
</Box>
);
}
@@ -0,0 +1,34 @@
import { Box } from '@mantine/core';
export default function FullscreenFrame({
fullscreen,
parent,
children,
}: {
fullscreen?: boolean;
parent?: HTMLElement | null;
children: React.ReactNode;
}) {
if (!fullscreen) return <>{children}</>;
return (
<Box
style={
parent
? {
width: '100%',
height: 'auto',
maxHeight: 'none',
overflow: 'visible',
}
: {
width: 'min(96vw, calc(100vw - 3rem))',
maxHeight: 'none',
overflow: 'visible',
}
}
>
{children}
</Box>
);
}
@@ -0,0 +1,23 @@
import { Center, Stack, Text } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
export default function Placeholder({
text,
Icon,
...props
}: {
text: string;
Icon: Icon;
onClick?: () => void;
}) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
@@ -0,0 +1,268 @@
import Asciinema from '@/components/render/Asciinema';
import Pdf from '@/components/render/Pdf';
import Render from '@/components/render/Render';
import { renderMode } from '@/components/render/renderMode';
import { useSettingsStore } from '@/lib/client/store/settings';
import type { File as DbFile } from '@/lib/db/models/file';
import {
Box,
Center,
Loader,
LoadingOverlay,
Image as MantineImage,
Paper,
Stack,
Text,
} from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import fileIcon from '../fileIcon';
import FileZoomModal from './FileZoomModal';
import FullscreenFrame from './FullscreenFrame';
import useFileContents from './useFileContent';
import useFileUrls, { isDbFile } from './useFileUrls';
export function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<Stack align='center'>
<Icon size='4rem' stroke={2} style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }} />
<Text size='md' ta='center'>
{text}
</Text>
</Stack>
</Center>
);
}
export default function DashboardFileType({
file,
show,
token,
code,
allowZoom,
fullscreen,
scrollParent,
}: {
file: DbFile | File;
show?: boolean;
token?: string | null;
code?: boolean;
allowZoom?: boolean;
fullscreen?: boolean;
scrollParent?: HTMLElement | null;
}) {
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const { fileUrl, thumbnailUrl, viewUrl } = useFileUrls({ file, token });
const db = isDbFile(file) ? file : null;
const extension = file.name.split('.').pop() || '';
const renderIn = renderMode(extension);
const type = code ? 'text' : file.type.split('/')[0];
const fileContent = useFileContents({ enabled: type === 'text', file, fileUrl });
const [zoomOpen, setZoomOpen] = useState(false);
useEffect(() => {
if (zoomOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [zoomOpen]);
if (disableMediaPreview && !show) {
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
}
if (db?.password === true && !show) {
return <Placeholder text={`Click to view protected ${file.name}`} Icon={IconShieldLockFilled} />;
}
if (db?.password === true && show) {
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
text={`Click to view protected ${file.name}`}
Icon={IconShieldLockFilled}
onClick={() => window.open(viewUrl!)}
/>
</Paper>
);
}
const isAsciicast = file.type === 'application/x-asciicast' || file.name.endsWith('.cast');
const mediaMax = fullscreen
? { maxWidth: 'min(96vw, calc(100vw - 3rem))', maxHeight: 'calc(100vh - 7.5rem)' }
: undefined;
if (type === 'video') {
if (!fileUrl) return <Loader />;
if (!show) {
if (thumbnailUrl) {
return (
<Box pos='relative'>
<MantineImage src={thumbnailUrl} alt={file.name || 'Video thumbnail'} />
<Center pos='absolute' inset={0}>
<IconPlayerPlay
size='4rem'
stroke={3}
style={{ filter: 'drop-shadow(0 0 10px rgba(0, 0, 0, 0.9))' }}
/>
</Center>
</Box>
);
}
return <Placeholder text={`Click to play video ${file.name}`} Icon={fileIcon(file.type)} />;
}
return (
<video
width='100%'
autoPlay
muted
controls
src={fileUrl}
style={{
cursor: 'pointer',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '85vw', maxHeight: '85vh' }),
}}
/>
);
}
if (type === 'image') {
if (!fileUrl) return <Loader />;
if (!show) {
return <MantineImage fit='contain' mah={400} src={fileUrl} alt={file.name || 'Image'} />;
}
return (
<Center>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
objectFit: 'contain',
...(fullscreen
? { ...mediaMax, width: 'auto', height: 'auto' }
: { maxWidth: '70vw', maxHeight: '70vw' }),
}}
onClick={() => allowZoom && setZoomOpen(true)}
/>
{allowZoom && zoomOpen && (
<FileZoomModal setOpen={setZoomOpen}>
<MantineImage
src={fileUrl}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
}}
/>
</FileZoomModal>
)}
</Center>
);
}
if (type === 'audio') {
if (!fileUrl) return <Loader />;
return show ? (
<audio autoPlay muted controls style={{ width: '100%' }} src={fileUrl} />
) : (
<Placeholder text={`Click to play audio ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (type === 'text') {
if (!show) return <Placeholder text={`Click to view text ${file.name}`} Icon={fileIcon(file.type)} />;
if (fileContent.trim() === '') {
return (
<LoadingOverlay
visible={fileContent.trim() === ''}
loaderProps={{
children: (
<>
<Center>
<Loader />
</Center>
<Text ta='center' mt='xs' c='dimmed'>
Loading file...
</Text>
</>
),
}}
/>
);
}
return (
<FullscreenFrame fullscreen={fullscreen} parent={scrollParent}>
<Render
mode={renderIn}
language={extension}
code={fileContent}
noClamp={fullscreen}
scrollParent={scrollParent}
/>
</FullscreenFrame>
);
}
if (isAsciicast) {
if (!fileUrl) return <Loader />;
return show ? (
<FullscreenFrame fullscreen={fullscreen}>
<Asciinema src={fileUrl} />
</FullscreenFrame>
) : (
<Placeholder
text={`Click to download asciinema cast ${file.name}`}
Icon={fileIcon('application/x-asciicast')}
/>
);
}
if (file.type === 'application/pdf') {
if (!fileUrl) return <Loader />;
return show ? (
fullscreen ? (
<Box style={{ height: 'calc(100vh - 7.5rem)', width: 'min(96vw, calc(100vw - 3rem))' }}>
<Pdf src={fileUrl} />
</Box>
) : (
<Pdf src={fileUrl} />
)
) : (
<Placeholder text={`Click to view PDF ${file.name}`} Icon={fileIcon(file.type)} />
);
}
if (!show) return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
return (
<Paper withBorder p='xs' style={{ cursor: 'pointer' }}>
<Placeholder
onClick={() => window.open(fileUrl)}
text={`Click to view file ${file.name} in a new tab`}
Icon={fileIcon(file.type)}
/>
</Paper>
);
}
@@ -0,0 +1,67 @@
import type { File as DbFile } from '@/lib/db/models/file';
import useSWR from 'swr';
import { isDbFile } from './useFileUrls';
const MAX_BYTES = 1 * 1024 * 1024;
const FILE_BIG = '\n...\nThe file is too big to display click the download icon to view/download it.';
async function readBlobText(file: File) {
const raw = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Failed to read file'));
reader.onload = () => resolve((reader.result ?? '') as string);
reader.readAsText(file);
});
return raw.length > MAX_BYTES ? raw.slice(0, MAX_BYTES) + FILE_BIG : raw;
}
async function readText(fileUrl: string) {
const res = await fetch(fileUrl, {
headers: {
Range: `bytes=0-${MAX_BYTES}`,
},
});
if (!res.ok) throw new Error('Failed to fetch file');
return await res.text();
}
export default function useFileContent({
enabled,
file,
fileUrl,
}: {
enabled: boolean;
file: DbFile | File;
fileUrl: string;
}) {
const { data, error } = useSWR<string>(
() => {
if (!enabled) return null;
if (isDbFile(file)) return ['dbfile', file.id] as const;
const f = file as File;
return ['blobfile', f.name] as const;
},
async () => {
if (!isDbFile(file)) return readBlobText(file as File);
if (file.size > MAX_BYTES) {
const text = await readText(fileUrl);
return text + FILE_BIG;
}
return readText(fileUrl);
},
{
revalidateOnFocus: false,
shouldRetryOnError: false,
},
);
if (error) return 'Error loading file.';
return data ?? '';
}
@@ -0,0 +1,36 @@
import { useUserStore } from '@/lib/client/store/user';
import type { File as DbFile } from '@/lib/db/models/file';
import { useMemo } from 'react';
function appendToken(url: string, token?: string | null) {
if (!token) return url;
return `${url}${token ? `?token=${encodeURIComponent(token)}` : ''}`;
}
export function isDbFile(file: DbFile | File): file is DbFile {
return typeof globalThis.File !== 'undefined' ? !(file instanceof globalThis.File) : 'thumbnail' in file;
}
export default function useFileUrls({ file, token }: { file: DbFile | File; token?: string | null }): {
fileUrl: string;
thumbnailUrl: string | null;
viewUrl: string | null;
} {
const user = useUserStore((state) => state.user);
const blobUrl = useMemo(() => (isDbFile(file) ? null : URL.createObjectURL(file as File)), [file]);
return useMemo(() => {
if (!isDbFile(file)) return { fileUrl: blobUrl ?? '', thumbnailUrl: null, viewUrl: null };
const thumb = file.thumbnail?.path;
const thumbnailUrl = thumb ? (user ? `/api/user/files/${thumb}/raw` : `/raw/${thumb}`) : null;
return {
fileUrl: appendToken(user ? `/api/user/files/${file.id}/raw` : `/raw/${file.name}`, token),
viewUrl: appendToken(`/view/${file.name}`, token),
thumbnailUrl,
};
}, [token, blobUrl, file, user]);
}
@@ -1,7 +1,8 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import useSWR from 'swr';
type ApiPaginationOptions = {
route?: string;
page?: number;
filter?: string;
perpage?: number;
@@ -26,14 +27,15 @@ type ApiPaginationOptions = {
};
};
const fetcher = async (
const fetcher = async <T,>(
{ options }: { options: ApiPaginationOptions; key: string } = {
options: {
page: 1,
},
key: '/api/user/files',
},
): Promise<Response['/api/user/files']> => {
): Promise<T> => {
const route = options.route ?? '/api/user/files';
const searchParams = new URLSearchParams();
if (options.page) searchParams.append('page', options.page.toString());
if (options.filter) searchParams.append('filter', options.filter);
@@ -48,7 +50,7 @@ const fetcher = async (
}
if (options.folderId) searchParams.append('folder', options.folderId);
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
const res = await fetch(`${route}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
if (!res.ok) {
const json = await res.json();
@@ -59,14 +61,18 @@ const fetcher = async (
return res.json();
};
export function useApiPagination(
export function useApiPagination<T = Response['/api/user/files']>(
options: ApiPaginationOptions = {
page: 1,
},
swrConfig?: Parameters<typeof useSWR<T>>[2],
) {
const { data, error, isLoading, mutate } = useSWR<Response['/api/user/files']>(
{ key: '/api/user/files', options },
{ fetcher },
const { data, error, isLoading, mutate } = useSWR<T>(
{ key: options.route ?? '/api/user/files', options },
{
fetcher: (k) => fetcher<T>(k),
...swrConfig,
},
);
return {
@@ -1,4 +1,5 @@
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import {
Button,
Center,
@@ -13,11 +14,14 @@ import {
Title,
} from '@mantine/core';
import { IconFilesOff, IconFileUpload } from '@tabler/icons-react';
import { lazy, Suspense, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useShallow } from 'zustand/shallow';
import DashboardFile from '@/components/file/DashboardFile';
import { useApiPagination } from '../useApiPagination';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
@@ -37,8 +41,28 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
const totalRecords = data?.total ?? 0;
const cachedPages = data?.pages ?? 1;
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const currentFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
return (
<>
<DashboardFileModal
open={!!currentFile}
setOpen={(open) => {
if (!open) setCurrent(null);
}}
file={currentFile}
user={id}
sequenced
/>
<SimpleGrid
my='sm'
cols={{
@@ -54,7 +78,7 @@ export default function Files({ id, folderId }: { id?: string; folderId?: string
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} id={id} />
<DashboardFile file={file} id={id} onOpen={(fileId) => setCurrent(fileId)} />
</Suspense>
))
) : (
@@ -5,6 +5,7 @@ import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { useFolders } from '@/lib/client/hooks/useFolders';
import { useQueryState } from '@/lib/client/hooks/useQueryState';
import { useFileNavStore } from '@/lib/client/store/fileNav';
import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings';
import { useSettingsStore } from '@/lib/client/store/settings';
import { type File } from '@/lib/db/models/file';
@@ -30,7 +31,7 @@ import {
Tooltip,
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useClipboard, useDebouncedValue } from '@mantine/hooks';
import {
IconCopy,
IconDownload,
@@ -43,6 +44,7 @@ import { DataTable } from 'mantine-datatable';
import { lazy, useEffect, useMemo, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
import { UpdateFn } from '@/lib/client/hooks/useObjectState';
import { DashboardFilesModals } from '..';
@@ -51,7 +53,7 @@ import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -231,7 +233,7 @@ export default function FileTable({
}),
{ name: '', originalName: '', type: '', tags: '', id: '' },
);
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -266,10 +268,15 @@ export default function FileTable({
}),
});
const [selectedFileId, setSelectedFile] = useState<string | null>(null);
const selectedFile = selectedFileId
? (data?.page.find((file) => file.id === selectedFileId) ?? null)
: null;
const [current, setCurrent, setFiles] = useFileNavStore(
useShallow((state) => [state.current, state.setCurrent, state.setFiles]),
);
const selectedFile = current ? (data?.page.find((file) => file.id === current) ?? null) : null;
const ids = useMemo(() => (data?.page ?? []).map((file) => file.id), [data?.page]);
useEffect(() => {
setFiles(ids);
}, [ids]);
const FIELDS = [
{
@@ -374,21 +381,16 @@ export default function FileTable({
const unfavoriteAll = selectedFiles.every((file) => file.favorite);
useEffect(() => {
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handler);
}, [searchQuery]);
return (
<>
<FileModal
<DashboardFileModal
open={!!selectedFile}
setOpen={(open) => {
if (!open) setSelectedFile(null);
if (!open) setCurrent(null);
}}
file={selectedFile}
user={id}
sequenced
/>
{modals && setModals && (
@@ -587,7 +589,7 @@ export default function FileTable({
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record.id)}
onCellClick={({ record }) => setCurrent(record.id)}
selectedRecords={selectedFiles}
onSelectedRecordsChange={setSelectedFiles}
paginationText={({ from, to, totalRecords }) => `${from} - ${to} / ${totalRecords} files`}
+32 -25
View File
@@ -14,32 +14,39 @@ export default function TotpModal({
}) {
return (
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<form onSubmit={onVerify}>
<Center>
<PinInput
length={6}
oneTimeCode
type='number'
onChange={onPinChange}
error={!!state.error}
disabled={state.disabled}
size='xl'
autoFocus
/>
</Center>
{state.error && (
<Text ta='center' size='sm' c='red' mt='xs'>
{state.error}
</Text>
)}
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button leftSection={<IconShieldQuestion size='1rem' />} loading={state.disabled} onClick={onVerify}>
Verify
</Button>
</Group>
<Group mt='sm' grow>
<Button leftSection={<IconX size='1rem' />} color='red' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button
leftSection={<IconShieldQuestion size='1rem' />}
loading={state.disabled}
onClick={onVerify}
type='submit'
>
Verify
</Button>
</Group>
</form>
</Modal>
);
}
@@ -189,7 +189,7 @@ export default function DashboardServerSettings() {
const location = useLocation();
const navigate = useNavigate();
const { data, isLoading } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const { data } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const [opened, { toggle }] = useDisclosure(false);
const toSettingSection = useCallback((settingKey: string) => {
@@ -299,10 +299,11 @@ export default function DashboardServerSettings() {
{(data?.tampered?.length ?? 0) > 0 && (
<Collapse expanded={opened} transitionDuration={180}>
<Alert
my='md'
color='red'
title='Environment Variable Settings'
mb='md'
icon={<IconExclamationMark size='1rem' />}
variant='outline'
>
<Text size='sm' mb='xs'>
These settings are controlled by environment variables:
@@ -327,7 +328,7 @@ export default function DashboardServerSettings() {
</Box>
}
>
<SettingsComponent swr={{ data, isLoading }} />
<SettingsComponent />
</Suspense>
</Box>
) : (
@@ -1,27 +1,34 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Chunks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Chunks() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} bdrs='md' />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
chunksEnabled: true,
chunksMax: '95mb',
chunksSize: '25mb',
chunksEnabled: data.settings.chunksEnabled,
chunksMax: data.settings.chunksMax,
chunksSize: data.settings.chunksSize,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'chunksEnabled' && !form.values.chunksEnabled) ||
false,
}),
@@ -29,49 +36,35 @@ export default function Chunks({
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
chunksEnabled: data.settings.chunksEnabled ?? true,
chunksMax: data.settings.chunksMax ?? '',
chunksSize: data.settings.chunksSize ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} bdrs='md' />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Max Chunk Size'
description='Maximum size of an upload before it is split into chunks.'
placeholder='95mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksMax')}
/>
<TextInput
label='Max Chunk Size'
description='Maximum size of an upload before it is split into chunks.'
placeholder='95mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksMax')}
/>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
</Stack>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,32 +1,34 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Core({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Core() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
coreTempDirectory: string;
coreTrustProxy: boolean;
}>({
const form = useForm({
initialValues: {
coreReturnHttpsUrls: false,
coreDefaultDomain: '',
coreTempDirectory: '/tmp/zipline',
coreTrustProxy: false,
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls,
coreDefaultDomain: data.settings.coreDefaultDomain,
coreTempDirectory: data.settings.coreTempDirectory,
coreTrustProxy: data.settings.coreTrustProxy,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -40,55 +42,40 @@ export default function Core({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
coreTrustProxy: data.settings.coreTrustProxy ?? false,
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<Switch
label='Trust Proxies'
description='Trust the X-Forwarded-* headers set by proxies. Only enable this if you are behind a trusted proxy (nginx, caddy, etc.). Requires a server restart.'
{...form.getInputProps('coreTrustProxy', { type: 'checkbox' })}
/>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
<TextInput
label='Temporary Directory'
description='The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.'
placeholder='/tmp/zipline'
{...form.getInputProps('coreTempDirectory')}
/>
</Stack>
<TextInput
label='Temporary Directory'
description='The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.'
placeholder='/tmp/zipline'
{...form.getInputProps('coreTempDirectory')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,4 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Button,
Collapse,
@@ -13,24 +13,31 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
type DiscordEmbed = Record<string, any>;
export default function Discord({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Discord() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const formMain = useForm({
initialValues: {
discordWebhookUrl: '',
discordUsername: '',
discordAvatarUrl: '',
discordWebhookUrl: data.settings.discordWebhookUrl,
discordUsername: data.settings.discordUsername,
discordAvatarUrl: data.settings.discordAvatarUrl,
},
});
@@ -49,42 +56,46 @@ export default function Discord({
const formOnUpload = useForm({
initialValues: {
discordOnUploadWebhookUrl: '',
discordOnUploadUsername: '',
discordOnUploadAvatarUrl: '',
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl,
discordOnUploadUsername: data.settings.discordOnUploadUsername,
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl,
discordOnUploadContent: '',
discordOnUploadContent: data.settings.discordOnUploadContent,
discordOnUploadEmbed: false,
discordOnUploadEmbedTitle: '',
discordOnUploadEmbedDescription: '',
discordOnUploadEmbedFooter: '',
discordOnUploadEmbedColor: '',
discordOnUploadEmbedThumbnail: false,
discordOnUploadEmbedImageOrVideo: false,
discordOnUploadEmbedTimestamp: false,
discordOnUploadEmbedUrl: false,
discordOnUploadEmbed: Boolean(data.settings.discordOnUploadEmbed),
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.title || '',
discordOnUploadEmbedDescription:
(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.description || '',
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.footer || '',
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.color || '',
discordOnUploadEmbedThumbnail: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.thumbnail,
discordOnUploadEmbedImageOrVideo: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)
?.imageOrVideo,
discordOnUploadEmbedTimestamp: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.timestamp,
discordOnUploadEmbedUrl: !!(data.settings.discordOnUploadEmbed as DiscordEmbed | null)?.url,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const formOnShorten = useForm({
initialValues: {
discordOnShortenWebhookUrl: '',
discordOnShortenUsername: '',
discordOnShortenAvatarUrl: '',
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl,
discordOnShortenUsername: data.settings.discordOnShortenUsername,
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl,
discordOnShortenContent: '',
discordOnShortenContent: data.settings.discordOnShortenContent,
discordOnShortenEmbed: false,
discordOnShortenEmbedTitle: '',
discordOnShortenEmbedDescription: '',
discordOnShortenEmbedFooter: '',
discordOnShortenEmbedColor: '',
discordOnShortenEmbedTimestamp: false,
discordOnShortenEmbedUrl: false,
discordOnShortenEmbed: Boolean(data.settings.discordOnShortenEmbed),
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.title || '',
discordOnShortenEmbedDescription:
(data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.description || '',
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.footer || '',
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.color || '',
discordOnShortenEmbedTimestamp: !!(data.settings.discordOnShortenEmbed as DiscordEmbed | null)
?.timestamp,
discordOnShortenEmbedUrl: !!(data.settings.discordOnShortenEmbed as DiscordEmbed | null)?.url,
},
});
@@ -123,56 +134,8 @@ export default function Discord({
return settingsOnSubmit(navigate, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
};
useEffect(() => {
if (!data) return;
formMain.setValues({
discordWebhookUrl: data.settings.discordWebhookUrl ?? '',
discordUsername: data.settings.discordUsername ?? '',
discordAvatarUrl: data.settings.discordAvatarUrl ?? '',
});
formOnUpload.setValues({
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl ?? '',
discordOnUploadUsername: data.settings.discordOnUploadUsername ?? '',
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl ?? '',
discordOnUploadContent: data.settings.discordOnUploadContent ?? '',
discordOnUploadEmbed: data.settings.discordOnUploadEmbed ? true : false,
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
discordOnUploadEmbedDescription:
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
discordOnUploadEmbedThumbnail: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
discordOnUploadEmbedImageOrVideo:
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
discordOnUploadEmbedTimestamp: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
discordOnUploadEmbedUrl: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
});
formOnShorten.setValues({
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl ?? '',
discordOnShortenUsername: data.settings.discordOnShortenUsername ?? '',
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl ?? '',
discordOnShortenContent: data.settings.discordOnShortenContent ?? '',
discordOnShortenEmbed: data.settings.discordOnShortenEmbed ? true : false,
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
discordOnShortenEmbedDescription:
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
discordOnShortenEmbedTimestamp:
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
discordOnShortenEmbedUrl: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={formMain.onSubmit(onSubmitMain)}>
<TextInput
label='Webhook URL'
@@ -1,19 +1,24 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { ActionIcon, LoadingOverlay, Paper, Table, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Domains({
swr: { data, isLoading },
}: {
swr: {
data: Response['/api/server/settings'] | undefined;
isLoading: boolean;
};
}) {
export default function Domains() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} /> : null}
</>
);
}
function Form({ data }: { data: Response['/api/server/settings'] }) {
const navigate = useNavigate();
const [submitting, setSubmitting] = useState(false);
@@ -24,7 +29,7 @@ export default function Domains({
const submitSettings = settingsOnSubmit(navigate, form);
const domains = Array.isArray(data?.settings.domains) ? data!.settings.domains.map(String) : [];
const domains = data.settings.domains.map(String);
async function updateDomains(nextDomains: string[]) {
setSubmitting(true);
@@ -56,7 +61,7 @@ export default function Domains({
return (
<>
<LoadingOverlay visible={isLoading || submitting} />
<LoadingOverlay visible={submitting} />
<form onSubmit={addDomain}>
<TextInput
@@ -1,4 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Anchor,
Button,
@@ -13,191 +13,175 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Features({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Features() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
featuresImageCompression: true,
featuresRobotsTxt: true,
featuresHealthcheck: true,
featuresUserRegistration: false,
featuresOauthRegistration: true,
featuresDeleteOnMaxViews: true,
featuresThumbnailsEnabled: true,
featuresThumbnailsNumberThreads: 4,
featuresThumbnailsFormat: 'jpg',
featuresThumbnailsInstantaneous: false,
featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true,
featuresVersionChecking: true,
featuresVersionAPI: 'https://zipline-version.diced.sh/',
featuresImageCompression: data.settings.featuresImageCompression,
featuresRobotsTxt: data.settings.featuresRobotsTxt,
featuresHealthcheck: data.settings.featuresHealthcheck,
featuresUserRegistration: data.settings.featuresUserRegistration,
featuresOauthRegistration: data.settings.featuresOauthRegistration,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat,
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous,
featuresMetricsEnabled: data.settings.featuresMetricsEnabled,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific,
featuresVersionChecking: data.settings.featuresVersionChecking,
featuresVersionAPI: data.settings.featuresVersionAPI,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
featuresImageCompression: data.settings.featuresImageCompression ?? true,
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresThumbnailsFormat: data.settings.featuresThumbnailsFormat ?? 'jpg',
featuresThumbnailsInstantaneous: data.settings.featuresThumbnailsInstantaneous ?? false,
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<Divider label='Thumbnails' />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<Switch
label='/robots.txt'
description='Enables a /robots.txt to stop search crawlers. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
label='Instantaneous Thumbnails'
description='Generates thumbnails immediately after a file is uploaded, instead of waiting for the task to run.'
{...form.getInputProps('featuresThumbnailsInstantaneous', { type: 'checkbox' })}
/>
</SimpleGrid>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<NumberInput
label='Thumbnails Number Threads'
description='Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.'
placeholder='Enter a number...'
min={1}
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<Divider label='Version Checking' />
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='https://zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
/>
</Stack>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<Divider label='Thumbnails' />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<Switch
label='Instantaneous Thumbnails'
description='Generates thumbnails immediately after a file is uploaded, instead of waiting for the task to run.'
{...form.getInputProps('featuresThumbnailsInstantaneous', { type: 'checkbox' })}
/>
</SimpleGrid>
<NumberInput
label='Thumbnails Number Threads'
description='Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.'
placeholder='Enter a number...'
min={1}
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Select
label='Thumbnails Format'
description='The output format for thumbnails. Requires a server restart.'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
]}
{...form.getInputProps('featuresThumbnailsFormat')}
/>
<Divider label='Version Checking' />
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='https://zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,52 +1,44 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Files({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Files() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
filesRoute: string;
filesLength: number;
filesDefaultFormat: string;
filesDisabledExtensions: string;
filesMaxFileSize: string;
filesDefaultExpiration: string | null;
filesMaxExpiration: string | null;
filesAssumeMimetypes: boolean;
filesDefaultDateFormat: string;
filesRemoveGpsMetadata: boolean;
filesRandomWordsNumAdjectives: number;
filesRandomWordsSeparator: string;
filesDefaultCompressionFormat: string;
filesMaxFilesPerUpload: number;
}>({
const form = useForm({
initialValues: {
filesRoute: '/u',
filesLength: 6,
filesDefaultFormat: 'random',
filesDisabledExtensions: '',
filesMaxFileSize: '100mb',
filesDefaultExpiration: '',
filesMaxExpiration: '',
filesAssumeMimetypes: false,
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: false,
filesRandomWordsNumAdjectives: 3,
filesRandomWordsSeparator: '-',
filesDefaultCompressionFormat: 'jpg',
filesMaxFilesPerUpload: 1000,
filesRoute: data.settings.filesRoute,
filesLength: data.settings.filesLength,
filesDefaultFormat: data.settings.filesDefaultFormat,
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '),
filesMaxFileSize: data.settings.filesMaxFileSize,
filesDefaultExpiration: data.settings.filesDefaultExpiration,
filesMaxExpiration: data.settings.filesMaxExpiration,
filesAssumeMimetypes: data.settings.filesAssumeMimetypes,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat,
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator,
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat,
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -85,143 +77,118 @@ export default function Files({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
filesRoute: data.settings.filesRoute ?? '/u',
filesLength: data.settings.filesLength ?? 6,
filesDefaultFormat: data.settings.filesDefaultFormat ?? 'random',
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
filesMaxExpiration: data.settings.filesMaxExpiration ?? '',
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat ?? 'jpg',
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload ?? 1000,
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<TextInput
label='Max Expiration'
description='The maximum expiration time allowed for files.'
placeholder='365d'
{...form.getInputProps('filesMaxExpiration')}
/>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<Select
label='Default Compression Format'
description='The default image compression format to use when only a compression percent is specified.'
placeholder='jpg'
data={[
{ value: 'jpg', label: '.jpg' },
{ value: 'png', label: '.png' },
{ value: 'webp', label: '.webp' },
{ value: 'jxl', label: '.jxl' },
]}
{...form.getInputProps('filesDefaultCompressionFormat')}
/>
<NumberInput
label='Max Files Per Upload'
description='The maximum number of files allowed per upload. Requires a server restart.'
min={1}
{...form.getInputProps('filesMaxFilesPerUpload')}
/>
</Stack>
<NumberInput
label='Max Files Per Upload'
description='The maximum number of files allowed per upload. Requires a server restart.'
min={1}
{...form.getInputProps('filesMaxFilesPerUpload')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,25 +1,32 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Stack, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function HttpWebhook({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function HttpWebhook() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
httpWebhookOnUpload: '',
httpWebhookOnShorten: '',
httpWebhookOnUpload: data.settings.httpWebhookOnUpload,
httpWebhookOnShorten: data.settings.httpWebhookOnShorten,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -37,40 +44,27 @@ export default function HttpWebhook({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='On Upload'
description='The URL to send a POST request to when a file is uploaded.'
placeholder='https://example.com/upload'
{...form.getInputProps('httpWebhookOnUpload')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='On Upload'
description='The URL to send a POST request to when a file is uploaded.'
placeholder='https://example.com/upload'
{...form.getInputProps('httpWebhookOnUpload')}
/>
<TextInput
label='On Shorten'
description='The URL to send a POST request to when a URL is shortened.'
placeholder='https://example.com/shorten'
{...form.getInputProps('httpWebhookOnShorten')}
/>
</Stack>
<TextInput
label='On Shorten'
description='The URL to send a POST request to when a URL is shortened.'
placeholder='https://example.com/shorten'
{...form.getInputProps('httpWebhookOnShorten')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,26 +1,33 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, Switch } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Invites({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Invites() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
invitesEnabled: true,
invitesLength: 6,
invitesEnabled: data.settings.invitesEnabled,
invitesLength: data.settings.invitesLength,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'invitesEnabled' && !form.values.invitesEnabled) ||
false,
}),
@@ -28,41 +35,28 @@ export default function Invites({
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
invitesEnabled: data.settings.invitesEnabled ?? true,
invitesLength: data.settings.invitesLength ?? 6,
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</Stack>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('invitesLength')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,90 +1,81 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, Divider, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Mfa({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Mfa() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
mfaTotpEnabled: false,
mfaTotpIssuer: 'Zipline',
mfaPasskeysEnabled: false,
mfaPasskeysRpID: '',
mfaPasskeysOrigin: '',
mfaTotpEnabled: data.settings.mfaTotpEnabled,
mfaTotpIssuer: data.settings.mfaTotpIssuer,
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled,
mfaPasskeysRpID: data.settings.mfaPasskeysRpID,
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
mfaPasskeysEnabled: data.settings.mfaPasskeysEnabled ?? false,
mfaPasskeysRpID: data.settings.mfaPasskeysRpID ?? '',
mfaPasskeysOrigin: data.settings.mfaPasskeysOrigin ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeysEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<TextInput
label='Relying Party ID'
description='The Relying Party ID (RP ID) to use for WebAuthn passkeys.'
placeholder='example.com'
{...form.getInputProps('mfaPasskeysRpID')}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<TextInput
label='Origin'
description='The Origin to use for WebAuthn passkeys.'
placeholder='https://example.com'
{...form.getInputProps('mfaPasskeysOrigin')}
/>
<Divider />
<Divider />
<Switch
label='Enable TOTP'
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Issuer'
description='The issuer to use for the TOTP token.'
placeholder='Zipline'
{...form.getInputProps('mfaTotpIssuer')}
/>
</Stack>
<Switch
label='Enable TOTP'
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
/>
<TextInput
label='Issuer'
description='The issuer to use for the TOTP token.'
placeholder='Zipline'
{...form.getInputProps('mfaTotpIssuer')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,4 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import {
Anchor,
Button,
@@ -13,45 +13,52 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Oauth({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Oauth() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
oauthBypassLocalLogin: false,
oauthLoginOnly: false,
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin,
oauthLoginOnly: data.settings.oauthLoginOnly,
oauthDiscordClientId: '',
oauthDiscordClientSecret: '',
oauthDiscordRedirectUri: '',
oauthDiscordAllowedIds: '',
oauthDiscordDeniedIds: '',
oauthDiscordClientId: data.settings.oauthDiscordClientId,
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret,
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri,
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds.join(', '),
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds.join(', '),
oauthGoogleClientId: '',
oauthGoogleClientSecret: '',
oauthGoogleRedirectUri: '',
oauthGoogleClientId: data.settings.oauthGoogleClientId,
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret,
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri,
oauthGithubClientId: '',
oauthGithubClientSecret: '',
oauthGithubRedirectUri: '',
oauthGithubClientId: data.settings.oauthGithubClientId,
oauthGithubClientSecret: data.settings.oauthGithubClientSecret,
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri,
oauthOidcClientId: '',
oauthOidcClientSecret: '',
oauthOidcAuthorizeUrl: '',
oauthOidcTokenUrl: '',
oauthOidcUserinfoUrl: '',
oauthOidcRedirectUri: '',
oauthOidcClientId: data.settings.oauthOidcClientId,
oauthOidcClientSecret: data.settings.oauthOidcClientSecret,
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl,
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl,
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl,
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -90,44 +97,8 @@ export default function Oauth({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin ?? false,
oauthLoginOnly: data.settings.oauthLoginOnly ?? false,
oauthDiscordClientId: data.settings.oauthDiscordClientId ?? '',
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret ?? '',
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri ?? '',
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds
? data.settings.oauthDiscordAllowedIds.join(', ')
: '',
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds
? data.settings.oauthDiscordDeniedIds.join(', ')
: '',
oauthGoogleClientId: data.settings.oauthGoogleClientId ?? '',
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret ?? '',
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri ?? '',
oauthGithubClientId: data.settings.oauthGithubClientId ?? '',
oauthGithubClientSecret: data.settings.oauthGithubClientSecret ?? '',
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri ?? '',
oauthOidcClientId: data.settings.oauthOidcClientId ?? '',
oauthOidcClientSecret: data.settings.oauthOidcClientSecret ?? '',
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl ?? '',
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl ?? '',
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl ?? '',
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
For OAuth to work, the &quot;OAuth Registration&quot; setting must be enabled in the{' '}
<Anchor component={Link} to='/dashboard/admin/settings/features'>
@@ -1,30 +1,37 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, ColorInput, Group, LoadingOverlay, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function PWA({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function PWA() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
pwaEnabled: false,
pwaTitle: '',
pwaShortName: '',
pwaDescription: '',
pwaThemeColor: '',
pwaBackgroundColor: '',
pwaEnabled: data.settings.pwaEnabled,
pwaTitle: data.settings.pwaTitle,
pwaShortName: data.settings.pwaShortName,
pwaDescription: data.settings.pwaDescription,
pwaThemeColor: data.settings.pwaThemeColor,
pwaBackgroundColor: data.settings.pwaBackgroundColor,
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'pwaEnabled' && !form.values.pwaEnabled) ||
false,
}),
@@ -48,23 +55,8 @@ export default function PWA({
});
};
useEffect(() => {
if (!data) return;
form.setValues({
pwaEnabled: data.settings.pwaEnabled ?? false,
pwaTitle: data.settings.pwaTitle ?? '',
pwaShortName: data.settings.pwaShortName ?? '',
pwaDescription: data.settings.pwaDescription ?? '',
pwaThemeColor: data.settings.pwaThemeColor ?? '',
pwaBackgroundColor: data.settings.pwaBackgroundColor ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
Refresh the page after enabling PWA to see any changes.
</Text>
@@ -1,35 +1,42 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, Switch, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Ratelimit({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Ratelimit() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm<{
ratelimitEnabled: boolean;
ratelimitMax: number;
ratelimitWindow: number | '';
ratelimitWindow: number | '' | null;
ratelimitAdminBypass: boolean;
ratelimitAllowList: string;
}>({
initialValues: {
ratelimitEnabled: true,
ratelimitMax: 10,
ratelimitWindow: '',
ratelimitAdminBypass: false,
ratelimitAllowList: '',
ratelimitEnabled: data.settings.ratelimitEnabled,
ratelimitMax: data.settings.ratelimitMax,
ratelimitWindow: data.settings.ratelimitWindow,
ratelimitAdminBypass: data.settings.ratelimitAdminBypass,
ratelimitAllowList: data.settings.ratelimitAllowList.join(', '),
},
enhanceGetInputProps: (payload: any): object => ({
disabled:
data?.tampered?.includes(payload.field) ||
data.tampered.includes(payload.field) ||
(payload.field !== 'ratelimitEnabled' && !form.values.ratelimitEnabled) ||
false,
}),
@@ -55,22 +62,8 @@ export default function Ratelimit({
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
ratelimitEnabled: data.settings.ratelimitEnabled ?? true,
ratelimitMax: data.settings.ratelimitMax ?? 10,
ratelimitWindow: data.settings.ratelimitWindow ?? '',
ratelimitAdminBypass: data.settings.ratelimitAdminBypass ?? false,
ratelimitAllowList: data.settings.ratelimitAllowList.join(', ') ?? '',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
All options require a restart to take effect.
</Text>
@@ -1,51 +1,43 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, Code, LoadingOverlay, Stack, Text, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Tasks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Tasks() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
tasksDeleteInterval: '30m',
tasksClearInvitesInterval: '30m',
tasksMaxViewsInterval: '30m',
tasksThumbnailsInterval: '30m',
tasksMetricsInterval: '30m',
tasksCleanThumbnailsInterval: '1d',
tasksDeleteInterval: data.settings.tasksDeleteInterval,
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval,
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval,
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval,
tasksMetricsInterval: data.settings.tasksMetricsInterval,
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
tasksDeleteInterval: data.settings.tasksDeleteInterval ?? '30m',
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval ?? '30m',
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
tasksCleanThumbnailsInterval: data.settings.tasksCleanThumbnailsInterval ?? '1d',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<Text size='sm' c='dimmed' mb='md'>
All options require a restart to take effect. Setting a value of <Code>0</Code> will disable the task.
</Text>
@@ -86,6 +78,13 @@ export default function Tasks({
placeholder='1d'
{...form.getInputProps('tasksCleanThumbnailsInterval')}
/>
<TextInput
label='Metrics Interval'
description='How often to collect metrics data. Setting this to a lower value will give you more up-to-date metrics, but may increase CPU usage.'
placeholder='30m'
{...form.getInputProps('tasksMetricsInterval')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
@@ -1,66 +1,60 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Stack, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
export default function Urls({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
export default function Urls() {
const { data, isLoading } = useServerSettings();
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
urlsRoute: '/go',
urlsLength: 6,
urlsRoute: data.settings.urlsRoute,
urlsLength: data.settings.urlsLength,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
urlsRoute: data.settings.urlsRoute ?? '/go',
urlsLength: data.settings.urlsLength ?? 6,
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Route'
description='The route to use for short URLs. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Route'
description='The route to use for short URLs. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</Stack>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -1,45 +1,41 @@
import { Response } from '@/lib/api/response';
import type { Response } from '@/lib/api/response';
import { Button, JsonInput, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings';
const defaultExternalLinks = [
{
name: 'GitHub',
url: 'https://github.com/diced/zipline',
},
{
name: 'Documentation',
url: 'https://zipline.diced.sh',
},
];
export default function Website() {
const { data, isLoading } = useServerSettings();
export default function Website({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
return (
<>
<LoadingOverlay visible={isLoading} />
{data ? <Form data={data} isLoading={isLoading} /> : null}
</>
);
}
function Form({ data, isLoading }: { data: Response['/api/server/settings']; isLoading: boolean }) {
const navigate = useNavigate();
const form = useForm({
initialValues: {
websiteTitle: 'Zipline',
websiteTitleLogo: '',
websiteExternalLinks: JSON.stringify(defaultExternalLinks),
websiteLoginBackground: '',
websiteLoginBackgroundBlur: true,
websiteDefaultAvatar: '',
websiteTos: '',
websiteTitle: data.settings.websiteTitle,
websiteTitleLogo: data.settings.websiteTitleLogo,
websiteExternalLinks: JSON.stringify(data.settings.websiteExternalLinks, null, 2),
websiteLoginBackground: data.settings.websiteLoginBackground,
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur,
websiteDefaultAvatar: data.settings.websiteDefaultAvatar,
websiteTos: data.settings.websiteTos,
websiteThemeDefault: 'system',
websiteThemeDark: 'builtin:dark_gray',
websiteThemeLight: 'builtin:light_gray',
websiteThemeDefault: data.settings.websiteThemeDefault,
websiteThemeDark: data.settings.websiteThemeDark,
websiteThemeLight: data.settings.websiteThemeLight,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
disabled: data.tampered.includes(payload.field) || false,
}),
});
@@ -59,12 +55,19 @@ export default function Website({
}
sendValues.websiteTitleLogo =
values.websiteTitleLogo.trim() === '' ? null : values.websiteTitleLogo.trim();
values.websiteTitleLogo?.trim() === '' || !values.websiteTitleLogo?.trim()
? null
: values.websiteTitleLogo.trim();
sendValues.websiteLoginBackground =
values.websiteLoginBackground.trim() === '' ? null : values.websiteLoginBackground.trim();
values.websiteLoginBackground?.trim() === '' || !values.websiteLoginBackground?.trim()
? null
: values.websiteLoginBackground.trim();
sendValues.websiteDefaultAvatar =
values.websiteDefaultAvatar.trim() === '' ? null : values.websiteDefaultAvatar.trim();
sendValues.websiteTos = values.websiteTos.trim() === '' ? null : values.websiteTos.trim();
values.websiteDefaultAvatar?.trim() === '' || !values.websiteDefaultAvatar?.trim()
? null
: values.websiteDefaultAvatar.trim();
sendValues.websiteTos =
values.websiteTos?.trim() === '' || !values.websiteTos?.trim() ? null : values.websiteTos.trim();
sendValues.websiteThemeDefault = values.websiteThemeDefault.trim();
sendValues.websiteThemeDark = values.websiteThemeDark.trim();
@@ -76,110 +79,92 @@ export default function Website({
return settingsOnSubmit(navigate, form)(sendValues);
};
useEffect(() => {
if (!data) return;
form.setValues({
websiteTitle: data.settings.websiteTitle ?? 'Zipline',
websiteTitleLogo: data.settings.websiteTitleLogo ?? '',
websiteExternalLinks: JSON.stringify(
data.settings.websiteExternalLinks ?? defaultExternalLinks,
null,
2,
),
websiteLoginBackground: data.settings.websiteLoginBackground ?? '',
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur ?? true,
websiteDefaultAvatar: data.settings.websiteDefaultAvatar ?? '',
websiteTos: data.settings.websiteTos ?? '',
websiteThemeDefault: data.settings.websiteThemeDefault ?? 'system',
websiteThemeDark: data.settings.websiteThemeDark ?? 'builtin:dark_gray',
websiteThemeLight: data.settings.websiteThemeLight ?? 'builtin:light_gray',
});
}, [data]);
return (
<>
<LoadingOverlay visible={isLoading} />
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap='lg'>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
<TextInput
label='Title Logo'
description='The URL to use for the title logo. This is placed to the left of the title.'
placeholder='https://example.com/logo.png'
{...form.getInputProps('websiteTitleLogo')}
/>
<TextInput
label='Title Logo'
description='The URL to use for the title logo. This is placed to the left of the title.'
placeholder='https://example.com/logo.png'
{...form.getInputProps('websiteTitleLogo')}
/>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON in the format of an array of objects with "name" and "url" properties. For example: [{"name": "GitHub", "url": "https://github.com/diced/zipline"}]'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(
[
{ name: 'GitHub', url: 'https://github.com/diced/zipline' },
{ name: 'Documentation', url: 'https://zipline.diced.sh' },
],
null,
2,
)}
{...form.getInputProps('websiteExternalLinks')}
/>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON in the format of an array of objects with "name" and "url" properties. For example: [{"name": "GitHub", "url": "https://github.com/diced/zipline"}]'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
{...form.getInputProps('websiteExternalLinks')}
/>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<TextInput
label='Default Avatar'
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
placeholder='/zipline/avatar.png'
{...form.getInputProps('websiteDefaultAvatar')}
/>
<TextInput
label='Default Avatar'
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
placeholder='/zipline/avatar.png'
{...form.getInputProps('websiteDefaultAvatar')}
/>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<TextInput
label='Dark Theme'
description='The dark theme to use for the website when the default theme is "system".'
placeholder='builtin:dark_gray'
{...form.getInputProps('websiteThemeDark')}
/>
<TextInput
label='Dark Theme'
description='The dark theme to use for the website when the default theme is "system".'
placeholder='builtin:dark_gray'
{...form.getInputProps('websiteThemeDark')}
/>
<TextInput
label='Light Theme'
description='The light theme to use for the website when the default theme is "system".'
placeholder='builtin:light_gray'
{...form.getInputProps('websiteThemeLight')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
</>
<TextInput
label='Light Theme'
description='The light theme to use for the website when the default theme is "system".'
placeholder='builtin:light_gray'
{...form.getInputProps('websiteThemeLight')}
/>
</Stack>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
);
}
@@ -0,0 +1,6 @@
import type { Response } from '@/lib/api/response';
import useSWR from 'swr';
export default function useServerSettings() {
return useSWR<Response['/api/server/settings']>('/api/server/settings');
}
@@ -25,7 +25,7 @@ import {
IconSettingsFilled,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
export default function SettingsAvatar() {
const user = useUserStore((state) => state.user);
@@ -36,14 +36,16 @@ export default function SettingsAvatar() {
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarSrc, setAvatarSrc] = useState<string | null>(null);
useEffect(() => {
(async () => {
if (!avatar) return;
const onAvatarChange = async (file: File | null) => {
setAvatar(file);
const base64url = await readToDataURL(avatar);
setAvatarSrc(base64url);
})();
}, [avatar]);
if (!file) {
setAvatarSrc(null);
return;
}
setAvatarSrc(await readToDataURL(file));
};
const saveAvatar = async () => {
if (!avatar) return;
@@ -111,7 +113,7 @@ export default function SettingsAvatar() {
accept='image/*'
placeholder='Upload new avatar...'
value={avatar}
onChange={(file) => setAvatar(file)}
onChange={onAvatarChange}
leftSection={<IconPhotoUp size='1rem' />}
/>
@@ -34,14 +34,14 @@ export default function SettingsDashboard() {
<Paper withBorder p='sm' h='100%'>
<Title order={2}>Dashboard Settings</Title>
<Text size='sm' c='dimmed' mt={3}>
These settings are saved in your browser.
These settings are saved automatically in your <b>browser.</b>
</Text>
<Stack gap='sm' my='xs'>
<Group grow>
<Stack>
<Switch
label='Disable Media Preview'
description='Disable previews of files in the dashboard. This is useful to save data as Zipline, by default, will load previews of files.'
description='Disable previews of files in the dashboard. This may help to save data and speed up the dashboard if you have a lot of media files, but it will also disable the file viewer and show a generic file icon instead of a preview for supported files.'
checked={settings.disableMediaPreview}
onChange={(event) => update('disableMediaPreview', event.currentTarget.checked)}
/>
@@ -51,7 +51,24 @@ export default function SettingsDashboard() {
checked={settings.warnDeletion}
onChange={(event) => update('warnDeletion', event.currentTarget.checked)}
/>
</Group>
<Switch
label='File navigation buttons'
description='Show previous/next on the right and left of the file viewer to easily navigate between files.'
checked={settings.fileNavButtons}
onChange={(event) => update('fileNavButtons', event.currentTarget.checked)}
/>
</Stack>
<Select
label='File viewer'
description='Choose which file viewer opens when you click a file.'
data={[
{ value: 'fullscreen', label: 'Fullscreen (beta)' },
{ value: 'default', label: 'Default (modal)' },
]}
value={settings.fileViewer}
onChange={(value) => update('fileViewer', (value as 'default' | 'fullscreen') ?? 'fullscreen')}
/>
<DomainSelect
label='Default Domain'
@@ -1,3 +1,4 @@
import type { User } from '@/lib/db/models/user';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/client/store/user';
@@ -27,7 +28,6 @@ import {
IconDeviceFloppy,
IconFileX,
} from '@tabler/icons-react';
import { useEffect } from 'react';
import { mutate } from 'swr';
import { useShallow } from 'zustand/shallow';
@@ -40,19 +40,34 @@ const alignIcons: Record<string, React.ReactNode> = {
export default function SettingsFileView() {
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
if (!user) {
return (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>
<Text c='dimmed' mt='xs'>
Loading
</Text>
</Paper>
);
}
return <Form user={user} setUser={setUser} />;
}
function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
const form = useForm({
initialValues: {
enabled: user?.view.enabled ?? false,
content: user?.view.content ?? '',
embed: user?.view.embed ?? false,
embedTitle: user?.view.embedTitle ?? '',
embedDescription: user?.view.embedDescription ?? '',
embedSiteName: user?.view.embedSiteName ?? '',
embedColor: user?.view.embedColor ?? '',
align: user?.view.align ?? 'left',
showMimetype: user?.view.showMimetype ?? false,
showTags: user?.view.showTags ?? false,
showFolder: user?.view.showFolder ?? false,
enabled: user.view.enabled || false,
content: user.view.content || '',
embed: user.view.embed || false,
embedTitle: user.view.embedTitle || '',
embedDescription: user.view.embedDescription || '',
embedSiteName: user.view.embedSiteName || '',
embedColor: user.view.embedColor || '',
align: user.view.align || 'left',
showMimetype: user.view.showMimetype || false,
showTags: user.view.showTags || false,
showFolder: user.view.showFolder || false,
},
});
@@ -95,24 +110,6 @@ export default function SettingsFileView() {
});
};
useEffect(() => {
if (user) {
form.setValues({
enabled: user.view.enabled || false,
content: user.view.content || '',
embed: user.view.embed || false,
embedTitle: user.view.embedTitle || '',
embedDescription: user.view.embedDescription || '',
embedSiteName: user.view.embedSiteName || '',
embedColor: user.view.embedColor || '',
align: user.view.align || 'left',
showMimetype: user.view.showMimetype || false,
showTags: user.view.showTags || false,
showFolder: user.view.showFolder || false,
});
}
}, [user]);
return (
<Paper withBorder p='sm'>
<Title order={2}>Viewing Files</Title>
@@ -1,3 +1,4 @@
import type { User } from '@/lib/db/models/user';
import { ApiError } from '@/lib/api/errors';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
@@ -25,29 +26,36 @@ import {
IconUser,
IconUserCancel,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { mutate } from 'swr';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
export default function SettingsUser() {
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
const { data: tokenPayload } = useSWR<Response['/api/user/token']>('/api/user/token');
if (!user) {
return (
<Paper withBorder p='sm'>
<Title order={2}>User</Title>
<Text c='dimmed' size='sm' mt='sm'>
Loading
</Text>
</Paper>
);
}
return <Form user={user} setUser={setUser} token={tokenPayload?.token ?? ''} />;
}
function Form({ user, setUser, token }: { user: User; setUser: (u: User) => void; token: string }) {
const [tokenShown, setTokenShown] = useState(false);
const [token, setToken] = useState('');
useEffect(() => {
(async () => {
const { data } = await fetchApi<Response['/api/user/token']>('/api/user/token');
if (data) {
setToken(data.token || '');
}
})();
}, []);
const form = useForm({
initialValues: {
username: user?.username ?? '',
username: user.username,
password: '',
},
validate: {
@@ -61,7 +69,7 @@ export default function SettingsUser() {
password?: string;
} = {};
if (values.username !== user?.username) send['username'] = values.username.trim();
if (values.username !== user.username) send['username'] = values.username.trim();
if (values.password) send['password'] = values.password.trim();
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', send);
@@ -84,6 +92,7 @@ export default function SettingsUser() {
if (!data?.user) return;
mutate('/api/user');
mutate('/api/user/token');
setUser(data.user);
notifications.show({
message: 'User updated',
@@ -96,7 +105,7 @@ export default function SettingsUser() {
<Paper withBorder p='sm'>
<Title order={2}>User</Title>
<Text c='dimmed' size='sm' mb='sm'>
{user?.id}
{user.id}
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
@@ -134,7 +143,7 @@ export default function SettingsUser() {
leftSection={<IconAsteriskSimple size='1rem' />}
/>
<Button type='submit' mt='md' loading={!user} leftSection={<IconDeviceFloppy size='1rem' />}>
<Button type='submit' mt='md' leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
</form>
@@ -50,17 +50,18 @@ export default function DropzoneFile({
</Center>
</Paper>
</HoverCard.Target>
<HoverCard.Dropdown>
<Group maw={400}>
<ScrollArea>
<Box mah={250} maw={400}>
<HoverCard.Dropdown p='md' maw={480}>
<Stack gap='sm'>
<ScrollArea h={240} offsetScrollbars type='auto'>
<Box w='100%' miw={280} style={{ maxWidth: 'min(92vw, 26rem)' }}>
<DashboardFileType file={file} show />
</Box>
</ScrollArea>
<Stack justify='xs'>
<Stack gap='xs'>
<Text size='sm' c='dimmed'>
<b>{file.name}</b> {file.type || file.type === '' ? `(${file.type})` : ''}
<b>{file.name}</b>
{file.type ? ` (${file.type})` : ''}
</Text>
<Text size='sm' c='dimmed'>
{bytes(file.size)}
@@ -76,7 +77,7 @@ export default function DropzoneFile({
Remove
</Button>
</Stack>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
+21 -18
View File
@@ -66,23 +66,12 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
}, []);
const upload = async () => {
const toPartialFiles: File[] = files.filter(
(file) => config.chunks.enabled && file.size >= bytes(config.chunks.max),
);
if (toPartialFiles.length > 0) {
uploadPartialFiles(toPartialFiles, {
setFiles,
setLoading,
setProgress,
clipboard,
clearEphemeral,
options,
ephemeral,
config,
folder,
});
} else {
const size = aggSize();
const maxBytes = config.chunks.enabled && bytes(config.chunks.max);
const partialUploads: File[] = maxBytes ? files.filter((file) => file.size >= maxBytes) : [];
const normalUploads: File[] = maxBytes ? files.filter((file) => file.size < maxBytes) : files;
if (normalUploads.length > 0) {
const size = normalUploads.reduce((acc, file) => acc + file.size, 0);
if (size > bytes(config.files.maxFileSize)) {
notifications.show({
title: 'Upload may fail',
@@ -98,7 +87,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
});
}
await uploadFiles(files, {
await uploadFiles(normalUploads, {
setFiles,
setLoading,
setProgress,
@@ -109,6 +98,20 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
folder,
});
}
if (partialUploads.length > 0) {
await uploadPartialFiles(partialUploads, {
setFiles,
setLoading,
setProgress,
clipboard,
clearEphemeral,
options,
ephemeral,
config,
folder,
});
}
};
useEffect(() => {
@@ -125,6 +125,7 @@ export default function UploadText() {
disabled={loading}
className={styles.textarea}
my='sm'
resize='vertical'
/>
<Group style={{ position: 'absolute', bottom: 10, right: 10 }} gap='xs'>
+17 -23
View File
@@ -1,31 +1,25 @@
import { Code, Image, Paper } from '@mantine/core';
import ReactMarkdown from 'react-markdown';
import { Paper, Typography } from '@mantine/core';
import Marked from 'marked-react';
import HighlightCode from './code/HighlightCode';
import remarkGfm from 'remark-gfm';
import { sanitize } from 'isomorphic-dompurify';
const components = {
code(value: string, language?: string) {
return <HighlightCode code={value} language={language ?? 'text'} />;
},
};
export default function Markdown({ md }: { md: string }) {
const cleanedMd = sanitize(md, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'code', 'pre', 'span'],
ALLOWED_ATTR: ['href', 'title', 'class'],
});
return (
<Paper withBorder p='md'>
<ReactMarkdown
components={{
code({ node: _, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<HighlightCode language={match[1]} code={String(children).replace(/\n$/, '')} />
) : (
<Code className={className} {...props}>
{children}
</Code>
);
},
img({ node: _, ...props }) {
return <Image {...props} />;
},
}}
remarkPlugins={[remarkGfm]}
>
{md}
</ReactMarkdown>
<Typography>
<Marked value={cleanedMd} gfm renderer={components} />
</Typography>
</Paper>
);
}
+19 -16
View File
@@ -1,10 +1,10 @@
import { RenderMode } from './renderMode';
import { Alert, Button } from '@mantine/core';
import { Alert, Button, Flex, Text } from '@mantine/core';
import { IconEyeFilled } from '@tabler/icons-react';
import { useState } from 'react';
import KaTeX from './KaTeX';
import Markdown from './Markdown';
import HighlightCode from './code/HighlightCode';
import { RenderMode } from './renderMode';
export function RenderAlert({
renderer,
@@ -22,17 +22,17 @@ export function RenderAlert({
mb='sm'
styles={{ message: { marginTop: 0 } }}
>
{!state ? `This file is rendered through ${renderer}` : `This file can be rendered through ${renderer}`}
<Button
mx='sm'
variant='outline'
size='compact-sm'
onClick={() => change(!state)}
pos='absolute'
right={0}
>
{state ? 'Show' : 'Hide'} rendered version
</Button>
<Flex align='center' justify='space-between' wrap='wrap' gap='md'>
<Text style={{ flex: 1, minWidth: '200px' }}>
{!state
? `This file is rendered through ${renderer}`
: `This file can be rendered through ${renderer}`}
</Text>
<Button size='compact-sm' onClick={() => change(!state)} w={{ base: '100%', xs: 'auto' }}>
{state ? 'Show' : 'Hide'} rendered version
</Button>
</Flex>
</Alert>
);
}
@@ -41,10 +41,13 @@ export default function Render({
mode,
language,
code,
...props
}: {
mode: RenderMode;
language: string;
code: string;
[key: string]: any;
}) {
const [highlight, setHighlight] = useState(false);
@@ -54,7 +57,7 @@ export default function Render({
<>
<RenderAlert renderer='KaTeX' state={highlight} change={(s) => setHighlight(s)} />
{highlight ? <HighlightCode language={language} code={code} /> : <KaTeX tex={code} />}
{highlight ? <HighlightCode language={language} code={code} {...props} /> : <KaTeX tex={code} />}
</>
);
case RenderMode.Markdown:
@@ -62,10 +65,10 @@ export default function Render({
<>
<RenderAlert renderer='Markdown' state={highlight} change={(s) => setHighlight(s)} />
{highlight ? <HighlightCode language={language} code={code} /> : <Markdown md={code} />}
{highlight ? <HighlightCode language={language} code={code} {...props} /> : <Markdown md={code} />}
</>
);
default:
return <HighlightCode language={language} code={code} />;
return <HighlightCode language={language} code={code} {...props} />;
}
}
+20 -15
View File
@@ -1,16 +1,22 @@
import { ActionIcon, Button, CopyButton, Paper, Text, useMantineTheme } from '@mantine/core';
import { IconCheck, IconChevronDown, IconChevronUp, IconClipboardCopy } from '@tabler/icons-react';
import type { HLJSApi } from 'highlight.js';
import * as sanitize from 'isomorphic-dompurify';
import { useEffect, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { useLocation } from 'react-router-dom';
import * as sanitize from 'isomorphic-dompurify';
import './HighlightCode.theme.scss';
export default function HighlightCode({ language, code }: { language: string; code: string }) {
const { pathname } = useLocation();
const noClamp = pathname.startsWith('/view/');
export default function HighlightCode({
language,
code,
noClamp,
scrollParent,
}: {
noClamp?: boolean;
language: string;
code: string;
scrollParent?: HTMLElement | null;
}) {
const theme = useMantineTheme();
const [expanded, setExpanded] = useState(false);
const [hljs, setHljs] = useState<HLJSApi | null>(null);
@@ -19,10 +25,8 @@ export default function HighlightCode({ language, code }: { language: string; co
import('highlight.js').then((mod) => setHljs(mod.default || mod));
}, []);
const cleanedCode = sanitize.sanitize(code, { USE_PROFILES: { html: true } });
const lines = cleanedCode.split('\n');
const lines = sanitize.sanitize(code, { USE_PROFILES: { html: true } }).split('\n');
const isExpandable = !noClamp && lines.length > 50;
const totalCount = isExpandable && !expanded ? 50 : lines.length;
const estimatedHeight = Math.min(totalCount * 24, 400);
@@ -38,6 +42,7 @@ export default function HighlightCode({ language, code }: { language: string; co
const rowRenderer = (index: number) => (
<div
key={index}
style={{
display: 'flex',
alignItems: 'flex-start',
@@ -45,7 +50,6 @@ export default function HighlightCode({ language, code }: { language: string; co
fontFamily: theme.fontFamilyMonospace,
fontSize: '0.8rem',
lineHeight: '1.5',
backgroundColor: 'transparent',
}}
>
<Text
@@ -77,7 +81,7 @@ export default function HighlightCode({ language, code }: { language: string; co
<ActionIcon
onClick={copy}
variant='outline'
color={copied ? 'green' : 'gray'}
color={copied ? 'green' : undefined}
size='md'
style={{ zIndex: 10, position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
@@ -90,13 +94,14 @@ export default function HighlightCode({ language, code }: { language: string; co
)}
</CopyButton>
<div style={{ height: noClamp && (expanded || !isExpandable) ? 'auto' : estimatedHeight }}>
<div style={{ height: noClamp ? undefined : estimatedHeight, overflowX: 'auto' }}>
<Virtuoso
style={{ height: '100%' }}
useWindowScroll={!!noClamp && !scrollParent}
customScrollParent={scrollParent ?? undefined}
style={{ height: noClamp ? undefined : '100%' }}
totalCount={totalCount}
itemContent={rowRenderer}
initialItemCount={30}
increaseViewportBy={200}
increaseViewportBy={400}
/>
</div>
+37
View File
@@ -0,0 +1,37 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
type AccessTokenPayload = {
type: string;
id: string;
expiry: number;
};
export function createAccessToken({ type, id }: { type: string; id: string }): string {
const payload: AccessTokenPayload = {
type: type,
id,
expiry: Date.now() + 5 * 60_000, // 5 minutes
};
return encrypt(JSON.stringify(payload), config.core.secret);
}
export function verifyAccessToken(token: string | null | undefined, type: string, id: string): boolean {
if (!token) return false;
try {
const raw = decrypt(token, config.core.secret);
const payload = JSON.parse(raw) as Partial<AccessTokenPayload>;
if (!payload || typeof payload !== 'object') return false;
if (payload.type !== type) return false;
if (payload.id !== id) return false;
if (typeof payload.expiry !== 'number') return false;
if (payload.expiry < Date.now()) return false;
return true;
} catch {
return false;
}
}
+29 -3
View File
@@ -59,11 +59,17 @@ export const API_ERRORS = {
1058: 'From date must be before to date',
1059: 'From date must be in the past',
1060: 'Passkey has legacy registration data and cannot be used',
1061: 'Invalid multipart/form-data request',
1062: 'No files in multipart/form-data request',
1063: 'Already linked to this OAuth provider',
1064: 'Invalid OAuth state parameter',
// 2xxx, session errors
2000: 'Invalid login session',
2001: 'Invalid token',
2002: 'Not logged in',
2003: 'OAuth provider is not configured (or misconfigured)',
2004: 'Invalid login steps (cookie relying on token)',
// 3xxx, permission errors
3000: 'Admin only',
@@ -82,6 +88,9 @@ export const API_ERRORS = {
3013: "You don't have permission to delete the selected files",
3014: "You don't have permission to modify the selected files",
3015: 'Not super admin',
3016: 'OAuth registration is disabled',
3017: 'OAuth login is not allowed for this account',
3018: 'Invalid access token provided.',
// 4xxx, not founds
4000: 'File not found',
@@ -107,6 +116,13 @@ export const API_ERRORS = {
6001: 'Failed to fetch version details',
6002: 'Failed to rename file in datasource',
6003: 'There was an error during a healthcheck',
6004: 'Failed to fetch OAuth access token',
6005: 'No access token in OAuth response',
6006: 'No refresh token in OAuth response',
6007: 'Failed to fetch OAuth user',
6008: 'OAuth provider request failed',
6009: "Couldn't create user via OAuth profile",
6010: 'The username is already taken by another account',
// 9xxx catch all
9000: 'Bad request',
@@ -126,14 +142,16 @@ export type ApiErrorPayload = {
};
export class ApiError extends Error {
public readonly code: ApiErrorCode;
public readonly status: number;
public additional: Record<string, any>;
constructor(code: ApiErrorCode, message?: string, status?: number) {
constructor(
public readonly code: ApiErrorCode,
message?: string,
status?: number,
) {
super(message ?? API_ERRORS[code] ?? 'Unknown API error');
this.code = code;
this.status = status ?? ApiError.codeToHttpStatus(code);
this.additional = {} as Record<string, any>;
@@ -182,3 +200,11 @@ export class ApiError extends Error {
return 500;
}
}
export class RedirectError extends Error {
constructor(public readonly url: string) {
super('Redirect');
Object.setPrototypeOf(this, new.target.prototype);
}
}
+29 -15
View File
@@ -7,6 +7,9 @@ import { Config } from '../config/validate';
import { sanitizeFilename } from '../fs';
import { formatFileName } from '../uploader/formatFileName';
import { guess } from '../mimes';
import { log } from '../logger';
const logger = log('upload');
const commonDoubleExts = [
'.tar.gz',
@@ -79,25 +82,36 @@ export async function getFilename(
extension: string,
override?: string,
): Promise<{ error: string } | { fileName: string }> {
let fileName = override ? sanitizeFilename(override) : formatFileName(format, originalName);
if (!fileName) return { error: 'invalid file name' };
try {
let fileName = override ? sanitizeFilename(override) : formatFileName(format, originalName);
let fullFileName = `${fileName}${extension}`;
let existing = await prisma.file.findFirst({ where: { name: fullFileName } });
if (existing && (override || format === 'name')) {
return { error: 'file with the same name already exists' };
}
while (existing && format === 'random') {
fileName = formatFileName(format, originalName);
if (!fileName) return { error: 'invalid file name' };
fullFileName = `${fileName}${extension}`;
existing = await prisma.file.findFirst({ where: { name: fullFileName } });
}
let fullFileName = `${fileName}${extension}`;
let existing = await prisma.file.findFirst({ where: { name: fullFileName } });
return { fileName };
if (existing && (override || format === 'name')) {
return { error: 'file with the same name already exists' };
}
let dateIncrement = 1;
while (existing && (format === 'random' || format === 'date')) {
fileName = formatFileName(format, originalName, dateIncrement++);
if (!fileName) return { error: 'invalid file name' };
fullFileName = `${fileName}${extension}`;
existing = await prisma.file.findFirst({ where: { name: fullFileName } });
}
return { fileName };
} catch (e) {
logger.warn(`error generating file name: ${e}`);
return {
error: e instanceof URIError ? 'invalid file name: make sure it is URL encoded' : 'invalid file name',
};
}
}
export async function getMimetype(
+9 -3
View File
@@ -2,13 +2,19 @@ import type { Response } from '@/lib/api/response';
import { isAdministrator } from '@/lib/role';
import { useEffect } from 'react';
import { redirect } from 'react-router-dom';
import useSWR from 'swr';
import useSWR, { SWRConfiguration } from 'swr';
import { useShallow } from 'zustand/shallow';
import { useUserStore } from '../store/user';
export default function useLogin(administratorOnly: boolean = false) {
export default function useLogin(
{ admin, swrConfig: swrOptions }: { admin?: boolean; swrConfig?: SWRConfiguration } = {
admin: false,
swrConfig: {},
},
) {
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
fallbackData: { user: undefined },
...swrOptions,
});
const [user, setUser] = useUserStore(useShallow((state) => [state.user, state.setUser]));
@@ -22,7 +28,7 @@ export default function useLogin(administratorOnly: boolean = false) {
}, [data, error]);
useEffect(() => {
if (user && administratorOnly && !isAdministrator(user.role)) {
if (user && admin && !isAdministrator(user.role)) {
redirect('/dashboard');
}
}, [user]);
+24
View File
@@ -0,0 +1,24 @@
import type { Response } from '@/lib/api/response';
import useSWR from 'swr';
async function fetcher(url: string): Promise<Response['/api/user'] | null> {
const res = await fetch(url);
if (!res.ok) return null;
return res.json();
}
export default function useUser(): {
user: Response['/api/user']['user'] | undefined;
loading: boolean;
} {
const { data, isLoading } = useSWR<Response['/api/user'] | null>('/api/user', fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
shouldRetryOnError: false,
});
return { user: data?.user, loading: isLoading };
}
+4 -3
View File
@@ -1,15 +1,16 @@
import useSWR from 'swr';
import { Response } from '../../api/response';
const f = async () => {
async function fetcher() {
const res = await fetch('/api/version');
if (!res.ok) throw new Error('Failed to fetch version');
const r = await res.json();
return r;
};
}
export default function useVersion() {
const { isLoading, data } = useSWR<Response['/api/version'], Error>('/api/version', f, {
const { isLoading, data } = useSWR<Response['/api/version'], Error>('/api/version', fetcher, {
refreshInterval: undefined,
revalidateOnFocus: false,
revalidateIfStale: false,
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand';
type FileNavStore = {
ids: string[];
current: string | null;
setFiles: (fileIds: string[]) => void;
setCurrent: (fileId: string | null) => void;
clear: () => void;
goPrev: () => void;
goNext: () => void;
};
export const useFileNavStore = create<FileNavStore>()((set) => ({
ids: [],
current: null,
setFiles: (fileIds) =>
set((state) => {
if (!state.current || fileIds.includes(state.current)) {
return { ids: fileIds };
}
return {
ids: fileIds,
current: null,
};
}),
setCurrent: (fileId) => set({ current: fileId }),
clear: () => set({ ids: [], current: null }),
goPrev: () =>
set((state) => {
if (!state.current) return state;
const idx = state.ids.indexOf(state.current);
if (idx <= 0) return state;
return {
current: state.ids[idx - 1],
};
}),
goNext: () =>
set((state) => {
if (!state.current) return state;
const idx = state.ids.indexOf(state.current);
if (idx < 0 || idx >= state.ids.length - 1) return state;
return {
current: state.ids[idx + 1],
};
}),
}));
+4
View File
@@ -5,6 +5,8 @@ export type SettingsStore = {
settings: {
disableMediaPreview: boolean;
warnDeletion: boolean;
fileNavButtons: boolean;
fileViewer: 'default' | 'fullscreen';
theme: string;
themeDark: string;
themeLight: string;
@@ -17,6 +19,8 @@ export type SettingsStore = {
const defaultSettings: SettingsStore['settings'] = {
disableMediaPreview: false,
warnDeletion: true,
fileNavButtons: true,
fileViewer: 'fullscreen',
theme: 'builtin:dark_blue',
themeDark: 'builtin:dark_blue',
themeLight: 'builtin:light_blue',
+8 -1
View File
@@ -268,14 +268,21 @@ export const schema = z.object({
rpID: z
.string()
.trim()
.refine(
(v) => v.length === 0 || /^[a-zA-Z0-9.-]+$/.test(v),
'RP ID can only contain letters, numbers, dots, and hyphens. Example: example.com, localhost, zipline.example.com.',
)
.transform((v) => (v.length > 0 ? v : null))
.nullable()
.default(null),
origin: z
.string()
.trim()
.refine(
(v) => v.length === 0 || /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?(\/.*)?$/.test(v),
'Origin must be a valid URL starting with http:// or https://',
)
.transform((v) => (v.length > 0 ? v : null))
.refine((v) => (v ? URL.canParse(v) : true), 'Invalid URL')
.nullable()
.default(null),
}),
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'crypto';
export function generatePKCEVerifier(size = 32): string {
return randomBytes(size).toString('base64url');
}
export function generatePKCEChallenge(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url');
}
-79
View File
@@ -1,79 +0,0 @@
import type { OAuthProviderType } from '@/prisma/client';
import { User } from '../db/models/user';
export function findProvider(
provider: OAuthProviderType,
providers: User['oauthProviders'],
): User['oauthProviders'][0] | undefined {
return providers.find((p) => p.provider === provider);
}
export const githubAuth = {
url: (clientId: string, state?: string, redirectUri?: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
state ? `&state=${encodeURIComponent(state)}` : ''
}${redirectUri ? `&redirect_uri=${encodeURIComponent(redirectUri)}` : ''}`,
user: async (accessToken: string) => {
const res = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const discordAuth = {
url: (clientId: string, origin: string, state?: string, redirectUri?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/discord`,
)}&response_type=code&scope=identify&prompt=none${state ? `&state=${encodeURIComponent(state)}` : ''}`,
user: async (accessToken: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const googleAuth = {
url: (clientId: string, origin: string, state?: string, redirectUri?: string) =>
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/google`,
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
state ? `&state=${encodeURIComponent(state)}` : ''
}`,
user: async (accessToken: string) => {
const res = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const oidcAuth = {
url: (clientId: string, origin: string, authorizeUrl: string, state?: string, redirectUri?: string) =>
`${authorizeUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/oidc`,
)}&response_type=code&scope=openid+email+profile+offline_access${state ? `&state=${encodeURIComponent(state)}` : ''}`,
user: async (accessToken: string, userInfoUrl: string) => {
const res = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
+22
View File
@@ -0,0 +1,22 @@
import { fetchUserInfo, type OAuthOptions, type OAuthUserInfoOptions } from '.';
export function discordAuthorizeURL({ clientId, origin, state, redirectUri }: OAuthOptions): string {
const u = new URL('https://discord.com/api/oauth2/authorize');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/discord`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'identify');
u.searchParams.set('prompt', 'none');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function discordUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://discord.com/api/users/@me',
...options,
});
}
+20
View File
@@ -0,0 +1,20 @@
import { fetchUserInfo, type OAuthOptions, type OAuthUserInfoOptions } from '.';
export function githubAuthorizeURL({ clientId, state, redirectUri, origin }: OAuthOptions): string {
const u = new URL('https://github.com/login/oauth/authorize');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/github`);
u.searchParams.set('scope', 'read:user');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function githubUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://api.github.com/user',
...options,
});
}
+22
View File
@@ -0,0 +1,22 @@
import { fetchUserInfo, type OAuthUserInfoOptions, type OAuthOptions } from '.';
export function googleAuthorizeURL({ clientId, origin, state, redirectUri }: OAuthOptions): string {
const u = new URL('https://accounts.google.com/o/oauth2/auth');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/google`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('access_type', 'offline');
u.searchParams.set('scope', 'https://www.googleapis.com/auth/userinfo.profile');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function googleUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
...options,
});
}
+40
View File
@@ -0,0 +1,40 @@
import type { OAuthProviderType } from '@/prisma/client';
import { User } from '../../db/models/user';
export function findProvider(
provider: OAuthProviderType,
providers: User['oauthProviders'],
): User['oauthProviders'][0] | undefined {
return providers.find((p) => p.provider === provider);
}
export async function fetchUserInfo({ userInfoUrl, accessToken }: OAuthUserInfoOptions): Promise<any | null> {
const res = await fetch(userInfoUrl!, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
return res.json();
}
export type OAuthOptions = {
clientId: string;
origin: string;
state?: string;
redirectUri: string;
authorizeUrl?: string;
codeChallenge?: string;
};
export type OAuthUserInfoOptions = {
accessToken: string;
userInfoUrl?: string;
};
export { discordAuthorizeURL, discordUser } from './discord';
export { githubAuthorizeURL, githubUser } from './github';
export { googleAuthorizeURL, googleUser } from './google';
export { oidcAuthorizeURL, oidcUser } from './oidc';
+32
View File
@@ -0,0 +1,32 @@
import { ApiError } from '@/lib/api/errors';
import { type OAuthOptions, type OAuthUserInfoOptions, fetchUserInfo } from '.';
export function oidcAuthorizeURL({
authorizeUrl,
clientId,
origin,
state,
redirectUri,
codeChallenge,
}: OAuthOptions): string {
if (!authorizeUrl) throw new ApiError(2003);
const u = new URL(authorizeUrl);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/oidc`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'openid email profile offline_access');
if (state) u.searchParams.set('state', state);
if (codeChallenge) {
u.searchParams.set('code_challenge_method', 'S256');
u.searchParams.set('code_challenge', codeChallenge);
}
return u.toString();
}
export function oidcUser(options: OAuthUserInfoOptions) {
return fetchUserInfo(options);
}
+40
View File
@@ -0,0 +1,40 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
export type OAuthStateJSON = {
mode: 'default' | 'link';
};
export function encryptOAuthState(value: OAuthStateJSON): string {
return encrypt(JSON.stringify(value), config.core.secret);
}
export function decryptOAuthState(state?: string): string | null {
if (!state) return null;
try {
return decrypt(decodeURIComponent(state), config.core.secret);
} catch {
return null;
}
}
export function parseOAuthState(state?: string): OAuthStateJSON | null {
const decrypted = decryptOAuthState(state);
if (!decrypted) return null;
// legacy
if (decrypted === 'link') return { mode: 'link' };
if (decrypted === 'default') return { mode: 'default' };
try {
const parsed = JSON.parse(decrypted) as Partial<OAuthStateJSON>;
if (parsed?.mode !== 'default' && parsed?.mode !== 'link') return null;
return {
mode: parsed.mode,
};
} catch {
return null;
}
}
+3 -1
View File
@@ -4,7 +4,8 @@ export function runThumbnailWorkers(workers: WorkerTask[], files: string[]) {
const thumbToWorker: { id: string; worker: number }[] = [];
let workerIndex = 0;
for (const file of files) {
const unique = new Set(files);
for (const file of unique) {
thumbToWorker.push({
id: file,
worker: workerIndex,
@@ -42,6 +43,7 @@ export default function thumbnails(prisma: typeof globalThis.__db__) {
type: {
startsWith: 'video/',
},
size: { gt: 0 },
},
});
if (!thumbnailNeeded.length) return;
+8 -5
View File
@@ -1,17 +1,20 @@
import { generateSecret, generateURI, verifySync } from 'otplib';
import { generateSecret, generateURI, verify } from 'otplib';
import { toDataURL } from 'qrcode';
export function generateKey() {
export function generateKey(): string {
return generateSecret({
length: 16,
});
}
export function verifyTotpCode(code: string, secret: string) {
return verifySync({
export async function verifyTotpCode(code: string, secret: string): Promise<boolean> {
const result = await verify({
secret,
token: code,
epochTolerance: 30,
});
return result.valid;
}
export function totpQrcode({
@@ -22,7 +25,7 @@ export function totpQrcode({
issuer?: string;
username: string;
secret: string;
}) {
}): Promise<string> {
return toDataURL(
generateURI({
secret,
+13 -7
View File
@@ -1,25 +1,31 @@
import { randomUUID } from 'crypto';
import dayjs from 'dayjs';
import { parse } from 'path';
import { config } from '../config';
import { Config } from '../config/validate';
import { sanitizeFilename } from '../fs';
import { randomCharacters } from '../random';
import { randomUUID } from 'crypto';
import { parse } from 'path';
import { randomWords } from './randomWords';
export function formatFileName(nameFormat: Config['files']['defaultFormat'], originalName?: string) {
export function formatFileName(
nameFormat: Config['files']['defaultFormat'],
originalName?: string,
dateIncrement?: number,
) {
switch (nameFormat) {
case 'random':
return randomCharacters(config.files.length);
case 'date':
return dayjs().format(config.files.defaultDateFormat);
return dayjs().format(config.files.defaultDateFormat) + (dateIncrement ? `-${dateIncrement}` : '');
case 'uuid':
return randomUUID({ disableEntropyCache: true });
case 'name':
const sanitized = originalName ? parse(originalName).name : null;
if (!originalName) return null;
const sanitized = sanitizeFilename(originalName);
if (!sanitized) return null;
const { name } = parse(sanitized);
return name;
return parse(sanitized).name;
case 'random-words':
case 'gfycat':
return randomWords(config.files.randomWordsNumAdjectives, config.files.randomWordsSeparator);
+22 -29
View File
@@ -1,8 +1,9 @@
import ms from 'ms';
import { Config } from '../config/validate';
import { checkOutput, COMPRESS_TYPES, CompressType } from '../compress';
import { config } from '../config';
import { sanitizeExtension, sanitizeFilename } from '../fs';
import { Config } from '../config/validate';
import { sanitizeExtension } from '../fs';
import { ApiError } from '../api/errors';
// from ms@3.0.0-canary.1
type Unit =
@@ -90,10 +91,6 @@ export type UploadOptions = {
folder?: string;
// error
header?: string;
message?: string;
// partials
partial?: {
filename: string;
@@ -140,20 +137,17 @@ export function parseExpiry(header: string): Date | null {
return human;
}
function parsePercent(header: keyof UploadHeaders, percent: string) {
const num = Number(percent);
if (isNaN(num)) return headerError(header, 'Invalid percent (NaN)');
if (num < 0 || num > 100) return headerError(header, 'Invalid percent (must be between 0 and 100)');
return num;
function throwHeaderError(header: keyof UploadHeaders, message: string): never {
throw new ApiError(1001, `bad options[${header}]: ${message}`);
}
function headerError(header: keyof UploadHeaders, message: string) {
return {
header,
message: `[${header}]: ${message}`,
};
function parsePercent(header: keyof UploadHeaders, percent: string) {
const num = Number(percent);
if (isNaN(num)) throwHeaderError(header, 'Invalid percent (NaN)');
if (num < 0 || num > 100) throwHeaderError(header, 'Invalid percent (must be between 0 and 100)');
return num;
}
const FORMATS = ['random', 'uuid', 'date', 'name', 'gfycat', 'random-words'];
@@ -166,14 +160,14 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
response.deletesAt = 'never' as any;
} else {
const expiresAt = parseExpiry(headers['x-zipline-deletes-at']);
if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date');
if (!expiresAt) throwHeaderError('x-zipline-deletes-at', 'Invalid expiry date');
if (fileConfig.maxExpiration) {
const maxExpiryTime = ms(fileConfig.maxExpiration as StringValue);
const requestedExpiryTime = expiresAt.getTime() - Date.now();
if (requestedExpiryTime > maxExpiryTime) {
return headerError(
throwHeaderError(
'x-zipline-deletes-at',
`Expiry exceeds maximum allowed expiration of ${fileConfig.maxExpiration}`,
);
@@ -191,7 +185,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
const format = headers['x-zipline-format'];
if (format) {
if (!FORMATS.includes(format)) return headerError('x-zipline-format', 'Invalid format');
if (!FORMATS.includes(format)) throwHeaderError('x-zipline-format', 'Invalid format');
response.format = format;
} else {
@@ -203,13 +197,13 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
if (imageCompressionType) {
if (!COMPRESS_TYPES.includes(imageCompressionType))
return headerError(
throwHeaderError(
'x-zipline-image-compression-type',
`Invalid compression type (must be one of: ${COMPRESS_TYPES.join(', ')})`,
);
if (!checkOutput(imageCompressionType))
return headerError(
throwHeaderError(
'x-zipline-image-compression-type',
`Compression type "${imageCompressionType}" is not supported on the system.`,
);
@@ -239,7 +233,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
const maxViews = headers['x-zipline-max-views'];
if (maxViews) {
const num = Number(maxViews);
if (isNaN(num)) return headerError('x-zipline-max-views', 'Invalid max views (NaN)');
if (isNaN(num)) throwHeaderError('x-zipline-max-views', 'Invalid max views (NaN)');
response.maxViews = num;
}
@@ -257,16 +251,15 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
const filename = headers['x-zipline-filename'];
if (filename) {
const fn = sanitizeFilename(filename);
if (!fn) return headerError('x-zipline-filename', 'Invalid filename');
// checks aren't needed here as they are sanitized later in getFilename
response.overrides.filename = fn;
response.overrides.filename = filename;
}
const extension = headers['x-zipline-file-extension'];
if (extension) {
const ext = sanitizeExtension(extension);
if (!ext) return headerError('x-zipline-file-extension', 'Invalid file extension');
if (!ext) throwHeaderError('x-zipline-file-extension', 'Invalid file extension');
response.overrides.extension = ext;
}
@@ -285,7 +278,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
.map((x) => Number(x));
if (isNaN(start) || isNaN(end) || isNaN(total))
return headerError('content-range', 'Invalid content-range');
throwHeaderError('content-range', 'Invalid content-range');
response.partial = {
filename: headers['x-zipline-p-filename']!,
+27 -8
View File
@@ -4,6 +4,7 @@ import { getDatasource } from '@/lib/datasource';
import { Datasource } from '@/lib/datasource/Datasource';
import type { File } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { randomCharacters } from '@/lib/random';
import ffmpeg from 'fluent-ffmpeg';
import { createWriteStream, existsSync, readFileSync, unlinkSync } from 'fs';
import { join } from 'path';
@@ -40,6 +41,8 @@ const formatMimes = {
webp: 'image/webp',
};
const workerId = randomCharacters(8);
function name(str: string) {
return `${str}.${config.features.thumbnails.format}`;
}
@@ -63,6 +66,7 @@ function genThumbnail(input: string, output: string): Promise<Buffer | undefined
`file ${input} does not contain any video stream, it is probably an audio file... ignoring...`,
);
resolve(Buffer.alloc(0));
return;
}
logger.error('failed to generate thumbnail', { err: err.message });
@@ -98,16 +102,26 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
},
});
if (!file) return;
if (!file) continue;
if (!file.type.startsWith('video/')) {
logger.debug('received file that is not a video', { id: file.id, type: file.type });
logger.debug('received file that is not a video, skipping', { id: file.id, type: file.type });
continue;
}
if (file.size === 0) {
logger.debug('thumbnail with file of 0 size, skipping', {
id: file.id,
});
continue;
}
const stream = await datasource.get(file.name);
if (!stream) return;
if (!stream) {
logger.debug('could not read file from datasource, skipping', { id: file.id });
continue;
}
const tmpFile = join(config.core.tempDirectory, `zthumbnail_${file.id}.tmp`);
const tmpFile = join(config.core.tempDirectory, `zthumbnail_${file.id}_${workerId}.tmp`);
const writeStream = createWriteStream(tmpFile);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
@@ -116,15 +130,14 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
writeStream.on('finish', resolve as any);
});
const thumbnailTmpFile = join(config.core.tempDirectory, name(`zthumbnail_${file.id}`));
const thumbnailTmpFile = join(config.core.tempDirectory, name(`zthumbnail_${file.id}_${workerId}`));
const thumbnail = await genThumbnail(tmpFile, thumbnailTmpFile);
if (!thumbnail) return;
if (!thumbnail || thumbnail.length === 0) continue;
const existing = await datasource.size(name(`.thumbnail.${file.id}`));
if (existing || existing === 0) {
await datasource.delete(name(`.thumbnail.${file.id}`));
}
await datasource.put(name(`.thumbnail.${file.id}`), thumbnail, {
mimetype: formatMimes[config.features.thumbnails.format] || 'image/jpeg',
});
@@ -172,7 +185,13 @@ async function main() {
switch (type) {
case 0:
logger.debug('received thumbnail generation request', { ids: data });
await generate(config, datasource, data!);
try {
await generate(config, datasource, data!);
} catch (err) {
logger.error('thumbnail generation failed', {
err: err instanceof Error ? err.message : String(err),
});
}
break;
case 1:
logger.debug('received kill request');
+17 -304
View File
@@ -1,44 +1,20 @@
import { ApiError } from '@/lib/api/errors';
import { bytes } from '@/lib/bytes';
import { reloadSettings } from '@/lib/config';
import { checkDbVars, REQUIRED_DB_VARS } from '@/lib/config/read/env';
import { getDatasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { runMigrations } from '@/lib/db/migration';
import { log } from '@/lib/logger';
import { isAdministrator } from '@/lib/role';
import { Tasks } from '@/lib/tasks';
import cleanThumbnails from '@/lib/tasks/run/cleanThumbnails';
import clearInvites from '@/lib/tasks/run/clearInvites';
import deleteFiles from '@/lib/tasks/run/deleteFiles';
import maxViews from '@/lib/tasks/run/maxViews';
import metrics from '@/lib/tasks/run/metrics';
import thumbnails from '@/lib/tasks/run/thumbnails';
import { fastifyCookie } from '@fastify/cookie';
import { fastifyCors } from '@fastify/cors';
import { fastifyMultipart } from '@fastify/multipart';
import { fastifyRateLimit } from '@fastify/rate-limit';
import { fastifySensible } from '@fastify/sensible';
import { fastifyStatic } from '@fastify/static';
import fastifySwagger from '@fastify/swagger';
import type { Tasks } from '@/lib/tasks';
import fastify from 'fastify';
import {
hasZodFastifySchemaValidationErrors,
isResponseSerializationError,
jsonSchemaTransform,
serializerCompiler,
validatorCompiler,
ZodTypeProvider,
} from 'fastify-type-provider-zod';
import { appendFile, mkdir, writeFile } from 'fs/promises';
import ms, { StringValue } from 'ms';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
import { mkdir } from 'fs/promises';
import { version } from '../../package.json';
import { checkRateLimit } from './plugins/checkRateLimit';
import oauthPlugin from './plugins/oauth';
import vitePlugin from './plugins/vite';
import loadRoutes from './routes';
import { filesRoute } from './routes/files.dy';
import { urlsRoute } from './routes/urls.dy';
import { registerHandlers } from './startup/handlers';
import { listenServer } from './startup/listen';
import { startMemoryLog } from './startup/memory';
import { generateOpenApiSpec } from './startup/openapi';
import { registerPlugins } from './startup/plugins';
import { registerRoutes } from './startup/routes';
import { startTasks } from './startup/tasks';
const MODE = process.env.NODE_ENV || 'production';
const logger = log('server');
@@ -86,279 +62,16 @@ async function main() {
trustProxy: config.core.trustProxy,
}).withTypeProvider<ZodTypeProvider>();
server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler);
await registerPlugins(server);
registerHandlers(server, MODE);
await registerRoutes(server, MODE);
await server.register(fastifySwagger, {
openapi: {
info: {
title: 'Zipline',
description: 'Zipline API',
version: version,
},
servers: [],
},
transform: jsonSchemaTransform,
});
if (process.env.ZIPLINE_OUTPUT_OPENAPI === 'true') generateOpenApiSpec(server);
await server.register(fastifyCookie, {
secret: config.core.secret,
hook: 'onRequest',
});
startTasks(server);
await listenServer(server);
await server.register(fastifyCors);
await server.register(fastifySensible);
await server.register(fastifyMultipart, {
limits: {
fileSize: bytes(config.files.maxFileSize),
parts: config.files.maxFilesPerUpload,
},
});
await server.register(fastifyStatic, {
serve: false,
root: config.core.tempDirectory,
});
await server.register(vitePlugin);
await server.register(oauthPlugin);
if (config.ratelimit.enabled) {
try {
checkRateLimit(config);
await server.register(fastifyRateLimit, {
global: false,
hook: 'preHandler',
max: config.ratelimit.max,
timeWindow: config.ratelimit.window ?? undefined,
keyGenerator: (req) => {
return `${req.user?.id ?? req.ip}-${req.url}-${req.method}`;
},
allowList: async (req, key) => {
if (config.ratelimit.adminBypass && isAdministrator(req.user?.role)) return true;
if (config.ratelimit.allowList.includes(key)) return true;
if (Object.keys(req.headers).includes('x-zipline-p-filename')) return true;
return false;
},
onExceeded(req, key) {
logger
.c('ratelimit')
.warn(`rate limit exceeded for user ${req.user?.username ?? req.ip ?? 'unknown'}`, { key });
},
});
} catch (e) {
if (process.env.DEBUG) console.error(e);
logger
.c('ratelimit')
.error((<Error>e).message)
.error('skipping ratelimit setup due to error above');
}
}
server.get<{ Params: { id: string } }>('/r/:id', async (req, res) => {
return res.redirect('/raw/' + req.params.id, 301);
});
server.get<{ Params: { id: string } }>('/view/:id', async (_req, res) => {
return res.ssr('view');
});
server.get<{ Params: { id: string } }>('/view/url/:id', async (_req, res) => {
return res.ssr('view-url');
});
if (config.files.route === '/' && config.urls.route === '/') {
logger.debug('files & urls route = /, using catch-all route');
server.get<{ Params: { id: string } }>('/:id', async (req, res) => {
const { id } = req.params;
if (id === '') return res.callNotFound();
else if (id === 'dashboard') return res.callNotFound(); // todo render dashboard
const url = await prisma.url.findFirst({
where: {
OR: [{ code: id }, { vanity: id }],
},
});
if (url) return urlsRoute(req as any, res);
else return filesRoute(req as any, res);
});
} else {
server.get(config.files.route === '/' ? '/:id' : `${config.files.route}/:id`, filesRoute);
server.get(config.urls.route === '/' ? '/:id' : `${config.urls.route}/:id`, urlsRoute);
}
const routes = await loadRoutes();
const routesOptions = Object.values(routes);
Promise.all(routesOptions.map((route) => server.register(route)));
if (MODE === 'production') {
server.serveIndex('/dashboard*');
server.serveIndex('/auth*');
server.serveIndex('/folder*');
}
server.get('/', (_, res) => res.redirect('/dashboard', 301));
server.setNotFoundHandler((req, res) => {
if (MODE === 'development' && server.vite)
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
dev: true,
});
if (req.url.startsWith('/api/')) {
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
});
} else {
return res.serveIndex();
}
});
server.setErrorHandler((error: any, _, res) => {
if (hasZodFastifySchemaValidationErrors(error)) {
return res.status(400).send({
error: error.message ?? 'E1000: Invalid response schema',
statusCode: 400,
code: 1000,
issues: error.validation,
});
}
if (isResponseSerializationError(error)) {
console.log(error);
return res.status(500).send({
error: 'E1000: Response serialization error',
statusCode: 500,
code: 1000,
details: error.message,
});
}
if (error instanceof ApiError) {
const apiError = error as ApiError;
return res.status(apiError.status).send(apiError.toJSON());
}
if (error.statusCode) {
return res.status(error.statusCode).send({ error: error.message, statusCode: error.statusCode });
} else {
console.error(error);
return res.status(500).send({
code: 9000,
error: 'E9000: Internal Server Error',
statusCode: 500,
});
}
});
const tasks = new Tasks();
server.decorate('tasks', tasks);
if (process.env.ZIPLINE_OUTPUT_OPENAPI === 'true') {
server.ready(async (a) => {
console.log(a);
const openapi = server.swagger();
await writeFile('./openapi.json', JSON.stringify(openapi, null, 2), 'utf8');
logger.info('OpenAPI schema written to openapi.json');
process.exit(0);
});
}
await server.listen({
port: config.core.port,
host: config.core.hostname,
});
logger.info('server started', { hostname: config.core.hostname, port: config.core.port });
// Tasks
tasks.interval('deletefiles', ms(config.tasks.deleteInterval as StringValue), deleteFiles(prisma));
tasks.interval('maxviews', ms(config.tasks.maxViewsInterval as StringValue), maxViews(prisma));
tasks.interval('clearinvites', ms(config.tasks.clearInvitesInterval as StringValue), clearInvites(prisma));
tasks.interval(
'cleanthumbnails',
ms(config.tasks.cleanThumbnailsInterval as StringValue),
cleanThumbnails(prisma),
);
if (config.features.metrics)
tasks.interval('metrics', ms(config.tasks.metricsInterval as StringValue), metrics(prisma));
if (config.features.thumbnails.enabled) {
tasks.interval('thumbnails', ms(config.tasks.thumbnailsInterval as StringValue), thumbnails(prisma));
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
tasks.worker(
`thumbnail-${i}`,
'./build/offload/thumbnails.js',
{
id: `thumbnail-${i}`,
enabled: config.features.thumbnails.enabled,
},
async function (this: Worker, message: any) {
if (message.type === 'query') {
const { id, query, data } = message;
let result: any = null;
switch (query) {
case 'file.findUnique':
result = await prisma.file.findUnique(data);
break;
case 'thumbnail.findFirst':
result = await prisma.thumbnail.findFirst(data);
break;
case 'thumbnail.create':
result = await prisma.thumbnail.create(data);
break;
case 'thumbnail.update':
result = await prisma.thumbnail.update(data);
break;
default:
console.error(`Unknown DB query: ${query}`);
}
this.postMessage({
type: 'response',
id,
result: JSON.stringify(result),
});
}
},
);
}
}
tasks.start();
if (process.env.DEBUG_MONITOR_MEMORY === 'true') {
await writeFile('.memory.log', '', 'utf8');
setInterval(async () => {
const mu = process.memoryUsage();
const cpu = process.cpuUsage();
const entry = `${Math.floor(Date.now() / 1000)},${mu.rss},${mu.heapUsed},${mu.heapTotal},${mu.external},${mu.arrayBuffers},${cpu.system},${cpu.user}\n`;
await appendFile('.memory.log', entry, 'utf8');
}, 1000);
}
if (process.env.ZIPLINE_MONITOR_MEMORY === 'true') startMemoryLog();
}
main();
+3 -8
View File
@@ -22,13 +22,12 @@ export function parseUserToken(
): string | null {
if (!encryptedToken) {
if (noThrow) return null;
throw { error: 'no token' };
throw new ApiError(2001);
}
const decryptedToken = decryptToken(encryptedToken, config.core.secret);
if (!decryptedToken) {
if (noThrow) return null;
// throw { error: 'could not decrypt token' };
throw new ApiError(2001);
}
@@ -56,12 +55,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
const authorization = req.headers.authorization;
if (authorization) {
try {
// eslint-disable-next-line no-var
var token = parseUserToken(authorization);
} catch (e) {
throw e;
}
const token = parseUserToken(authorization);
const user = await prisma.user.findFirst({
where: {
@@ -77,6 +71,7 @@ export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
}
const session = await getSession(req, res);
if (session.tokenAuth) throw new ApiError(2004);
if (!session.id || !session.sessionId) throw new ApiError(2000);
+28 -42
View File
@@ -1,29 +1,28 @@
import { config } from '@/lib/config';
import { createToken, decrypt } from '@/lib/crypto';
import { createToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import Logger, { log } from '@/lib/logger';
import { findProvider } from '@/lib/oauth/providers';
import { OAuthProviderType, User } from '@/prisma/client';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { getSession, saveSession } from '../session';
import { getSession, saveSession, ZiplineIronSession } from '../session';
import { parseOAuthState } from '@/lib/oauth/state';
import { ApiError } from '@/lib/api/errors';
export type OAuthQuery = {
state?: string;
code: string;
host: string;
session: ZiplineIronSession;
};
export type OAuthResponse = {
username?: string;
user_id?: string;
access_token?: string;
refresh_token?: string;
username: string;
user_id: string;
access_token: string;
refresh_token?: string | null;
avatar?: string | null;
error?: string;
error_code?: number;
redirect?: string;
};
async function oauthPlugin(fastify: FastifyInstance) {
@@ -36,23 +35,17 @@ async function oauthPlugin(fastify: FastifyInstance) {
handler: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) {
const logger = log('api').c('auth').c('oauth').c(provider.toLowerCase());
(this.query as any).host = this.headers.host ?? 'localhost:3000';
const response = await handler(this.query as OAuthQuery, logger);
const session = await getSession(this, reply);
if (response.error) {
logger.warn('invalid oauth request', {
error: response.error,
});
const q = this.query as { state?: string; code?: string };
const query: OAuthQuery = {
state: q.state,
code: q.code ?? '',
host: this.headers.host ?? 'localhost:3000',
session,
};
return reply.internalServerError(response.error);
}
if (response.redirect) {
return reply.redirect(response.redirect);
}
const response = await handler(query, logger);
logger.debug('oauth response', {
response,
@@ -77,7 +70,8 @@ async function oauthPlugin(fastify: FastifyInstance) {
},
});
const { state } = this.query as OAuthQuery;
const state = parseOAuthState(query.state);
if (!state) throw new ApiError(1064);
const user = await prisma.user.findFirst({
where: {
@@ -91,21 +85,12 @@ async function oauthPlugin(fastify: FastifyInstance) {
oauthProviders: true,
},
});
const userOauth = findProvider(provider, user?.oauthProviders ?? []);
let urlState;
try {
urlState = decrypt(decodeURIComponent(state ?? ''), config.core.secret);
} catch {
urlState = null;
}
if (state.mode === 'link') {
if (!user) throw new ApiError(2000);
if (urlState === 'link') {
if (!user) return reply.unauthorized('invalid session');
if (findProvider(provider, user.oauthProviders))
return reply.badRequest('This account is already linked to this provider');
if (findProvider(provider, user.oauthProviders)) throw new ApiError(1063);
logger.debug('attempting to link oauth account', {
provider,
@@ -145,7 +130,7 @@ async function oauthPlugin(fastify: FastifyInstance) {
error: e,
});
return reply.badRequest('Cant link account, already linked with this provider');
throw new ApiError(1063);
}
} else if (user && userOauth) {
await prisma.oAuthProvider.update({
@@ -199,9 +184,10 @@ async function oauthPlugin(fastify: FastifyInstance) {
oauth: response.username || 'unknown',
ua: this.headers['user-agent'],
});
return reply.badRequest("Can't create users through oauth.");
throw new ApiError(6009);
} else if (existingUser) {
return reply.badRequest('This username is already taken');
throw new ApiError(6010);
}
try {
@@ -222,7 +208,7 @@ async function oauthPlugin(fastify: FastifyInstance) {
},
});
await saveSession(session, <User>nuser);
await saveSession(session, <User>nuser, false);
logger.info('created user with oauth', {
provider,
@@ -242,7 +228,7 @@ async function oauthPlugin(fastify: FastifyInstance) {
response,
});
return reply.badRequest('Cant create user, already linked with this provider');
throw new ApiError(1063);
} else throw e;
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ export default typedPlugin(
}
if (user.totpSecret && code) {
const valid = verifyTotpCode(code, user.totpSecret);
const valid = await verifyTotpCode(code, user.totpSecret);
if (!valid) {
logger.warn('invalid totp code', {
username,
+23 -35
View File
@@ -1,38 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { discordAuth } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { discordAuthorizeURL, discordUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { discord: discordEnabled } = enabled(config);
if (!discordEnabled)
return {
error: 'Discord OAuth is not configured.',
error_code: 401,
};
if (!discordEnabled) throw new ApiError(2003, 'Discord OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: discordAuth.url(
config.oauth.discord.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state === 'link' ? linkState : undefined,
config.oauth.discord.redirectUri ?? undefined,
),
};
throw new RedirectError(
discordAuthorizeURL({
clientId: config.oauth.discord.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.discord.redirectUri!,
}),
);
}
const body = new URLSearchParams({
@@ -65,29 +56,26 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
text,
});
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.refresh_token) return { error: 'No refresh token in response' };
if (!json.access_token) throw new ApiError(6005);
if (!json.refresh_token) throw new ApiError(6006);
const userJson = await discordAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await discordUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { '@me': userJson });
const allowedIds = config.oauth.discord.allowedIds;
const deniedIds = config.oauth.discord.deniedIds;
if (deniedIds && deniedIds.length > 0 && deniedIds.includes(userJson.id)) {
return { error: 'You are not allowed to log in with Discord.' };
}
if (allowedIds && allowedIds.length > 0 && !allowedIds.includes(userJson.id)) {
return { error: 'You are not allowed to log in with Discord.' };
}
if (deniedIds && deniedIds.length > 0 && deniedIds.includes(userJson.id)) throw new ApiError(3017);
if (allowedIds && allowedIds.length > 0 && !allowedIds.includes(userJson.id)) throw new ApiError(3017);
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
+21 -30
View File
@@ -1,37 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { githubAuth } from '@/lib/oauth/providers';
import { githubAuthorizeURL, githubUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { github: githubEnabled } = enabled(config);
if (!githubEnabled)
return {
error: 'GitHub OAuth is not configured.',
error_code: 401,
};
if (!githubEnabled) throw new ApiError(2003, 'GitHub OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: githubAuth.url(
config.oauth.github.clientId!,
state === 'link' ? linkState : undefined,
config.oauth.github.redirectUri ?? undefined,
),
};
throw new RedirectError(
githubAuthorizeURL({
clientId: config.oauth.github.clientId!,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.github.redirectUri!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
}),
);
}
const body = JSON.stringify({
@@ -55,10 +47,7 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise
const isJson = res.headers.get('content-type')?.startsWith('application/json');
if (!isJson && !res.ok)
return {
error: 'Failed to fetch access token',
};
if (!isJson && !res.ok) throw new ApiError(6004);
const json = await res.json();
@@ -68,13 +57,15 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise
});
logger.debug('failed to fetch access token', { json, status: res.status });
return { error: 'there was an error while processing github request' };
throw new ApiError(6008);
}
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await githubAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await githubUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { user: userJson });
+19 -28
View File
@@ -1,38 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { googleAuth } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { googleAuthorizeURL, googleUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { google: googleEnabled } = enabled(config);
if (!googleEnabled)
return {
error: 'Google OAuth is not configured.',
error_code: 401,
};
if (!googleEnabled) throw new ApiError(2003, 'Google OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: googleAuth.url(
config.oauth.google.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state === 'link' ? linkState : undefined,
config.oauth.google.redirectUri ?? undefined,
),
};
throw new RedirectError(
googleAuthorizeURL({
clientId: config.oauth.google.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.google.redirectUri!,
}),
);
}
const body = new URLSearchParams({
@@ -63,16 +54,16 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P
text,
});
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await googleAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await googleUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { userinfo: userJson });
+38 -30
View File
@@ -1,40 +1,44 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { oidcAuth } from '@/lib/oauth/providers';
import { generatePKCEChallenge, generatePKCEVerifier } from '@/lib/oauth/pkce';
import { oidcAuthorizeURL, oidcUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
async function oidcOauth({ code, host, state, session }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { oidc: oidcEnabled } = enabled(config);
if (!oidcEnabled)
return {
error: 'OpenID Connect OAuth is not configured.',
error_code: 401,
};
if (!oidcEnabled) throw new ApiError(2003, 'OpenID Connect OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
const defaultState = encrypt('default', config.core.secret);
const pkceVerifier = generatePKCEVerifier();
const codeChallenge = generatePKCEChallenge(pkceVerifier);
return {
redirect: oidcAuth.url(
config.oauth.oidc.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
config.oauth.oidc.authorizeUrl!,
state === 'link' ? linkState : defaultState,
config.oauth.oidc.redirectUri ?? undefined,
),
};
session.pkceVerifier = pkceVerifier;
await session.save();
throw new RedirectError(
oidcAuthorizeURL({
clientId: config.oauth.oidc.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.oidc.redirectUri!,
authorizeUrl: config.oauth.oidc.authorizeUrl!,
codeChallenge,
}),
);
}
const pkceVerifier = session.pkceVerifier;
if (pkceVerifier) {
delete session.pkceVerifier;
await session.save();
}
const body = new URLSearchParams({
@@ -46,6 +50,9 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro
config.oauth.oidc.redirectUri ??
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/oidc`,
});
if (pkceVerifier) {
body.set('code_verifier', pkceVerifier);
}
logger.debug('oidc oauth request', {
body: body.toString(),
@@ -63,16 +70,17 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro
const text = await res.text();
logger.debug('oidc oauth failed with a non 200 status code', { status: res.status, text });
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await oidcAuth.user(json.access_token, config.oauth.oidc.userinfoUrl!);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await oidcUser({
accessToken: json.access_token,
userInfoUrl: config.oauth.oidc.userinfoUrl!,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { userinfo: userJson });
+55 -12
View File
@@ -1,11 +1,17 @@
import { ApiError } from '@/lib/api/errors';
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file';
import { buildPublicParentChain, cleanFolder, Folder, folderSchema } from '@/lib/db/models/folder';
import { paginationQs } from '@/lib/validation';
import typedPlugin from '@/server/typedPlugin';
import z from 'zod';
export type ApiServerFolderResponse = Partial<Folder>;
export type ApiServerFolderResponse = {
folder: Partial<Folder>;
page: File[];
total: number;
pages: number;
};
export const PATH = '/api/server/folder/:id';
export default typedPlugin(
@@ -18,21 +24,29 @@ export default typedPlugin(
params: z.object({
id: z.string(),
}),
querystring: paginationQs.pick({
page: true,
perpage: true,
sortBy: true,
order: true,
}),
response: {
200: folderSchema.partial(),
200: z.object({
folder: folderSchema.partial(),
page: z.array(fileSchema),
total: z.number(),
pages: z.number(),
}),
},
},
},
async (req, res) => {
const { id } = req.params;
const { page, perpage, sortBy, order } = req.query;
const folder = await prisma.folder.findUnique({
where: { id },
include: {
files: {
select: { ...fileSelect, password: true, tags: false },
orderBy: { createdAt: 'desc' },
},
children: {
where: { public: true },
orderBy: { createdAt: 'desc' },
@@ -54,12 +68,34 @@ export default typedPlugin(
if (!folder) throw new ApiError(9002);
if (!folder.public && !folder.allowUploads) throw new ApiError(9002);
const where = { folderId: folder.id };
const total = await prisma.file.count({ where });
const pages = total === 0 ? 0 : Math.ceil(total / perpage);
const files = cleanFiles(
await prisma.file.findMany({
where,
select: { ...fileSelect, password: true, tags: false },
orderBy: {
[sortBy]: order,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}),
true,
);
if (!folder.public && folder.allowUploads) {
return res.send({
id: folder.id,
name: folder.name,
allowUploads: folder.allowUploads,
public: folder.public,
folder: {
id: folder.id,
name: folder.name,
allowUploads: folder.allowUploads,
public: folder.public,
},
page: [],
total,
pages,
});
}
@@ -67,7 +103,14 @@ export default typedPlugin(
folder.parent = await buildPublicParentChain(folder.parentId);
}
return res.send(cleanFolder(folder, true));
const cleanedFolder = cleanFolder(folder, true);
return res.send({
folder: cleanedFolder,
page: files,
total,
pages,
});
},
);
},
@@ -332,11 +332,19 @@ export default typedPlugin(
mfaPasskeysRpID: z
.string()
.trim()
.refine(
(v) => v.length === 0 || /^[a-zA-Z0-9.-]+$/.test(v),
'RP ID can only contain letters, numbers, dots, and hyphens. Example: example.com, localhost, zipline.example.com.',
)
.transform((v) => (v.length === 0 ? null : v))
.nullable(),
mfaPasskeysOrigin: z
.string()
.trim()
.refine(
(v) => v.length === 0 || /^https?:\/\/[a-zA-Z0-9.-]+(:\d+)?(\/.*)?$/.test(v),
'Origin must be a valid URL starting with http:// or https://',
)
.transform((v) => (v.length === 0 ? null : v))
.nullable(),
+15 -2
View File
@@ -16,6 +16,7 @@ import { onUpload } from '@/lib/webhooks';
import { Prisma } from '@/prisma/client';
import { userMiddleware } from '@/server/middleware/user';
import typedPlugin from '@/server/typedPlugin';
import { SavedMultipartFile } from '@fastify/multipart';
import { stat } from 'fs/promises';
import { z } from 'zod';
@@ -84,7 +85,6 @@ export default typedPlugin(
},
async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) throw new ApiError(1001, `bad options: ${options.message}`);
if (options.partial) throw new ApiError(1001, 'bad options, receieved: partial upload');
@@ -99,7 +99,20 @@ export default typedPlugin(
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
}
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
let files: SavedMultipartFile[] = [];
try {
const res = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
files = res.files;
} catch (e) {
logger.warn('error parsing multipart/form-data request', {
error: e instanceof Error ? e.message : e,
});
if (e instanceof Error && e.message.startsWith('Multipart:')) throw new ApiError(1061);
}
if (!files.length) throw new ApiError(1062);
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
const quotaCheck = await checkQuota(req.user, totalFileSize, files.length);
+2 -2
View File
@@ -74,7 +74,7 @@ export default typedPlugin(
},
async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) throw new ApiError(1001, 'bad options, receieved: ' + JSON.stringify(options));
if (!options.partial) throw new ApiError(1004);
if (!options.partial.range || options.partial.range.length !== 3) throw new ApiError(1002);
@@ -89,7 +89,7 @@ export default typedPlugin(
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
}
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
const { files } = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
const response: ApiUploadPartialResponse = {
files: [],
@@ -1,4 +1,5 @@
import { ApiError } from '@/lib/api/errors';
import { createAccessToken } from '@/lib/accessToken';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
@@ -20,7 +21,8 @@ export default typedPlugin(
PATH,
{
schema: {
description: 'Verify the password for a password-protected file by ID or name.',
description:
'Verify the password for a password-protected file by ID or name and receive an access token if the password is correct',
body: z.object({
password: zStringTrimmed,
}),
@@ -30,6 +32,7 @@ export default typedPlugin(
response: {
200: z.object({
success: z.boolean(),
token: z.string(),
}),
},
},
@@ -59,17 +62,12 @@ export default typedPlugin(
throw new ApiError(3005);
}
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
res.cookie('file_pw_' + file.id, req.body.password, {
sameSite: 'lax',
maxAge: 60,
httpOnly: false,
secure: false,
path: '/',
logger.info(`${file.name} was accessed with the correct password, a new access token was created`, {
ua: req.headers['user-agent'],
});
return res.send({ success: true });
const token = createAccessToken({ type: 'file', id: file.id });
return res.send({ success: true, token });
},
);
},
+5 -6
View File
@@ -1,7 +1,7 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { sanitizeFilename } from '@/lib/fs';
@@ -27,7 +27,7 @@ export default typedPlugin(
id: z.string(),
}),
querystring: z.object({
pw: z.string().optional(),
token: z.string().optional(),
download: zQsBoolean.optional(),
}),
tags: ['auth'],
@@ -35,7 +35,7 @@ export default typedPlugin(
preHandler: [userMiddleware],
},
async (req, res) => {
const { pw, download } = req.query;
const { token, download } = req.query;
const id = sanitizeFilename(req.params.id);
if (!id) throw new ApiError(9002);
@@ -114,9 +114,8 @@ export default typedPlugin(
}
if (file?.password) {
if (!pw) throw new ApiError(3004);
const verified = await verifyPassword(pw, file.password!);
if (!verified) throw new ApiError(3005);
const valid = verifyAccessToken(token, 'file', file.id);
if (!valid) throw new ApiError(3018);
}
const size = file?.size || (await datasource.size(file?.name ?? id));
+2 -2
View File
@@ -90,7 +90,7 @@ export default typedPlugin(
async (req, res) => {
const { code, secret } = req.body;
const valid = verifyTotpCode(code, secret);
const valid = await verifyTotpCode(code, secret);
if (!valid) throw new ApiError(1045);
const user = await prisma.user.update({
@@ -126,7 +126,7 @@ export default typedPlugin(
const { code } = req.body;
const valid = verifyTotpCode(code, req.user.totpSecret);
const valid = await verifyTotpCode(code, req.user.totpSecret);
if (!valid) throw new ApiError(1045);
const user = await prisma.user.update({
+4 -6
View File
@@ -24,7 +24,7 @@ export default typedPlugin(
'List the current browser session and other active sessions for the authenticated user.',
response: {
200: z.object({
current: userSessionSchema,
current: userSessionSchema.nullable(),
other: z.array(userSessionSchema),
}),
},
@@ -37,10 +37,8 @@ export default typedPlugin(
const currentDbSession = req.user.sessions.find((session) => session.id === currentSession.sessionId);
if (!currentDbSession) throw new ApiError(2000);
return res.send({
current: currentDbSession,
current: currentDbSession ?? null,
other: req.user.sessions.filter((session) => session.id !== currentSession.sessionId),
});
},
@@ -57,7 +55,7 @@ export default typedPlugin(
}),
response: {
200: z.object({
current: userSessionSchema,
current: userSessionSchema.nullable(),
other: z.array(userSessionSchema),
}),
},
@@ -122,7 +120,7 @@ export default typedPlugin(
});
return res.send({
current: user.sessions.find((session) => session.id === currentSession.sessionId)!,
current: user.sessions.find((session) => session.id === currentSession.sessionId) ?? null,
other: user.sessions.filter((session) => session.id !== currentSession.sessionId),
});
},
@@ -1,4 +1,5 @@
import { ApiError } from '@/lib/api/errors';
import { createAccessToken } from '@/lib/accessToken';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
@@ -9,6 +10,7 @@ import z from 'zod';
export type ApiUserUrlsIdPasswordResponse = {
success: boolean;
token: string;
};
const logger = log('api').c('user').c('urls').c('[id]').c('password');
@@ -27,6 +29,12 @@ export default typedPlugin(
body: z.object({
password: zStringTrimmed,
}),
response: {
200: z.object({
success: z.boolean(),
token: z.string(),
}),
},
},
...secondlyRatelimit(2),
},
@@ -58,15 +66,8 @@ export default typedPlugin(
ua: req.headers['user-agent'],
});
res.cookie('url_pw_' + url.id, req.body.password, {
sameSite: 'lax',
maxAge: 60,
httpOnly: false,
secure: false,
path: '/',
});
return res.send({ success: true });
const token = createAccessToken({ type: 'url', id: url.id });
return res.send({ success: true, token });
},
);
},
+1 -1
View File
@@ -7,7 +7,7 @@ type Params = {
};
type Query = {
pw?: string;
token?: string;
download?: string;
};
+7 -8
View File
@@ -1,23 +1,24 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { TimedCache } from '@/lib/timedCache';
import typedPlugin from '@/server/typedPlugin';
import { FastifyReply, FastifyRequest } from 'fastify';
const viewsCache = new Map<string, number>();
const VIEW_WINDOW = 5 * 1000;
const viewsCache = new TimedCache<string, number>(VIEW_WINDOW);
type Params = {
id: string;
};
type Querystring = {
pw?: string;
token?: string;
download?: string;
};
@@ -31,7 +32,7 @@ export const rawFileHandler = async (
res: FastifyReply,
) => {
const { id } = req.params;
const { pw, download } = req.query;
const { token, download } = req.query;
if (id.startsWith('.thumbnail')) {
const thumbnail = await prisma.thumbnail.findFirst({
@@ -79,10 +80,8 @@ export const rawFileHandler = async (
}
if (file?.password) {
if (!pw) throw new ApiError(3004);
const verified = await verifyPassword(pw, file.password!);
if (!verified) throw new ApiError(3005);
const valid = verifyAccessToken(token, 'file', file.id);
if (!valid) throw new ApiError(3018);
}
const size = file?.size || (await datasource.size(file?.name ?? id));
+5 -7
View File
@@ -1,5 +1,5 @@
import { verifyAccessToken } from '@/lib/accessToken';
import { config } from '@/lib/config';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { FastifyReply, FastifyRequest } from 'fastify';
@@ -9,7 +9,7 @@ type Params = {
};
type Query = {
pw?: string;
token?: string;
};
const logger = log('server').c('urls');
@@ -19,7 +19,7 @@ export async function urlsRoute(
res: FastifyReply,
) {
const { id } = req.params;
const { pw } = req.query;
const { token } = req.query;
const url = await prisma.url.findFirst({
where: {
@@ -48,10 +48,8 @@ export async function urlsRoute(
}
if (url.password) {
if (!pw) return res.redirect(`/view/url/${url.id}`);
const verified = await verifyPassword(pw as string, url.password);
if (!verified) return res.redirect(`/view/url/${url.id}`);
const valid = verifyAccessToken(token, 'url', url.id);
if (!valid) return res.redirect(`/view/url/${url.id}`);
}
await prisma.url.update({
+15 -1
View File
@@ -2,9 +2,11 @@ import { detectClient, ZiplineClient } from '@/lib/api/detect';
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { randomCharacters } from '@/lib/random';
import { parse } from 'cookie';
import { FastifyReply, FastifyRequest } from 'fastify';
import { IncomingMessage, ServerResponse } from 'http';
import { getIronSession, type SessionOptions } from 'iron-session';
import { parseUserToken } from './middleware/user';
const cookieOptions: NonNullable<SessionOptions['cookieOptions']> = {
// 2 weeks
@@ -20,8 +22,13 @@ export type ZiplineSession = {
id: string | null;
sessionId: string | null;
client: ZiplineClient;
pkceVerifier?: string;
tokenAuth?: boolean;
};
export type ZiplineIronSession = Awaited<ReturnType<typeof getSession>>;
export async function getSession(
req: FastifyRequest | IncomingMessage,
reply: FastifyReply | ServerResponse<IncomingMessage>,
@@ -43,12 +50,19 @@ export async function getSession(
const headers = (req as FastifyRequest).headers || (req as IncomingMessage).headers;
session.client = detectClient(<Record<string, string>>headers);
const cookies = parse(headers.cookie || '');
if (headers['authorization'] && !cookies['zipline_session']) {
const token = parseUserToken(headers['authorization'], true);
if (token) session.tokenAuth = true;
}
return session;
}
export async function saveSession(
session: Awaited<ReturnType<typeof getSession>>,
session: ZiplineIronSession,
user: { id: string } & Record<string, any>,
overwriteSessions = true,
) {
+69
View File
@@ -0,0 +1,69 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import type { FastifyInstance } from 'fastify';
import { hasZodFastifySchemaValidationErrors, isResponseSerializationError } from 'fastify-type-provider-zod';
export function registerHandlers(server: FastifyInstance, mode: string) {
server.setNotFoundHandler((req, res) => {
if (mode === 'development' && server.vite)
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
dev: true,
});
if (req.url.startsWith('/api/')) {
return res.status(404).send({
message: `Route ${req.method}:${req.url} not found`,
error: 'Not Found',
statusCode: 404,
});
} else {
res.status(404);
return res.serveIndex();
}
});
server.setErrorHandler((error: any, _, res) => {
if (hasZodFastifySchemaValidationErrors(error)) {
return res.status(400).send({
error: error.message ?? 'E1000: Invalid response schema',
statusCode: 400,
code: 1000,
issues: error.validation,
});
}
if (isResponseSerializationError(error)) {
console.log(error);
return res.status(500).send({
error: 'E1000: Response serialization error',
statusCode: 500,
code: 1000,
details: error.message,
});
}
if (error instanceof RedirectError) {
return res.redirect(error.url);
}
if (error instanceof ApiError) {
const apiError = error as ApiError;
return res.status(apiError.status).send(apiError.toJSON());
}
if (error.statusCode) {
return res.status(error.statusCode).send({ error: error.message, statusCode: error.statusCode });
} else {
console.error(error);
return res.status(500).send({
code: 9000,
error: 'E9000: Internal Server Error',
statusCode: 500,
});
}
});
}
+46
View File
@@ -0,0 +1,46 @@
import { log } from '@/lib/logger';
import type { FastifyInstance } from 'fastify';
import { lstat, unlink } from 'fs/promises';
const logger = log('server');
export async function unixSocketPath() {
const config = global.__config__;
const path = config.core.hostname.trim();
if (!path.startsWith('/')) return null;
try {
const stat = await lstat(path);
if (!stat.isSocket()) logger.warn('existing file at unix socket path, removing', { path });
await unlink(path);
logger.warn('removed existing unix socket before listen', { path });
return path;
} catch (error) {
logger.warn('error while checking for existing unix socket', { path, error });
process.exit(1);
}
}
export async function listenServer(server: FastifyInstance) {
const config = global.__config__;
const socketPath = await unixSocketPath();
if (socketPath) {
await server.listen({
path: socketPath,
});
logger.info('server started with unix socket', { path: socketPath });
return;
}
await server.listen({
port: config.core.port,
host: config.core.hostname,
});
logger.info('server started', { hostname: config.core.hostname, port: config.core.port });
}
+13
View File
@@ -0,0 +1,13 @@
import { appendFile, writeFile } from 'fs/promises';
export async function startMemoryLog() {
await writeFile('.memory.log', '', 'utf8');
setInterval(async () => {
const mu = process.memoryUsage();
const cpu = process.cpuUsage();
const entry = `${Math.floor(Date.now() / 1000)},${mu.rss},${mu.heapUsed},${mu.heapTotal},${mu.external},${mu.arrayBuffers},${cpu.system},${cpu.user}\n`;
await appendFile('.memory.log', entry, 'utf8');
}, 1000);
}
+16
View File
@@ -0,0 +1,16 @@
import { log } from '@/lib/logger';
import type { FastifyInstance } from 'fastify';
import { writeFile } from 'fs/promises';
const logger = log('server');
export function generateOpenApiSpec(server: FastifyInstance) {
server.ready(async (a) => {
console.log(a);
const openapi = server.swagger();
await writeFile('./openapi.json', JSON.stringify(openapi, null, 2), 'utf8');
logger.info('OpenAPI schema written to openapi.json');
process.exit(0);
});
}

Some files were not shown because too many files have changed in this diff Show More