mirror of
https://github.com/diced/zipline.git
synced 2026-06-30 01:33:40 -07:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae11a29057 | |||
| 4776d9e85f | |||
| 41b63e6f25 | |||
| 24b332c23e | |||
| 3fd9154e57 | |||
| 3f71769ec6 | |||
| a99b0f4f1d | |||
| 15f5279ddb | |||
| 87a2dfbda6 | |||
| c7d2b3010f | |||
| 5119806147 | |||
| 33104ce1be | |||
| eeb1c51fb2 | |||
| 756dee6bba | |||
| a0907e8791 | |||
| 5a58abeb51 | |||
| 72d8c693c7 | |||
| 7caf314ce1 | |||
| 677927b4a6 | |||
| ac0b718f77 | |||
| db3a1b88ad | |||
| a97cf32682 | |||
| 7e2b4ed1bb | |||
| a7fdf5afed | |||
| db8adcc768 | |||
| 135cf1982a | |||
| 9925300e9d | |||
| 3bf125b4b4 | |||
| dc9abe4383 | |||
| 1ccbc878f8 | |||
| aa43f66570 | |||
| 7e3bba5e55 | |||
| 82e1fe4824 | |||
| 818d3f5518 | |||
| 23c131f45a | |||
| 3c5fd8effe | |||
| 377e3dc73d | |||
| f75457da1c | |||
| d6b0ba3b16 | |||
| 1a1bc46667 | |||
| eb1c39933a | |||
| b070dbf432 | |||
| 8af5ad05d6 |
+30
-31
@@ -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"
|
||||
|
||||
Generated
+1331
-2157
File diff suppressed because it is too large
Load Diff
@@ -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't have an account?{' '}
|
||||
<Anchor component={Link} to='/auth/register' c='blue' fw={500}>
|
||||
Register
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 "
|
||||
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</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 "{search}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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 "OAuth Registration" 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>
|
||||
);
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']!,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ type Params = {
|
||||
};
|
||||
|
||||
type Query = {
|
||||
pw?: string;
|
||||
token?: string;
|
||||
download?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user