mirror of
https://github.com/diced/zipline.git
synced 2026-07-01 01:54:44 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0bcb4a019 | |||
| 4c86b7fc38 | |||
| 9b7759520c | |||
| e3e77c7916 | |||
| 13282988e8 | |||
| 00f4254227 | |||
| 669c61eae0 | |||
| 1ee1aca589 | |||
| d49fd6a1f0 | |||
| 8128e3deb0 | |||
| 67a9fe34b4 | |||
| 7a3c4223ec | |||
| cb2590aae5 | |||
| 93ff18a120 | |||
| 4343f130fb | |||
| 5e9778d18a | |||
| 9bcccbc8aa |
@@ -91,6 +91,9 @@ volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Zipline requires a cpu with AVX support. We don't provide binaries or images that have support for non-AVX cpus
|
||||
|
||||
### Volumes
|
||||
|
||||
- `./uploads` - The folder where all the user uploads are stored (the default is `./uploads`)
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export default defineConfig(
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
react: { version: '19' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+30
-31
@@ -2,15 +2,15 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.5.1",
|
||||
"version": "4.5.3",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:nd": "cross-env NODE_ENV=development tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
"dev:inspector": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require ./src/dotenv.js ./build/server",
|
||||
"start:inspector": "cross-env NODE_ENV=production node --require ./src/dotenv.js --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||
"ctl": "NODE_ENV=production node --require ./src/dotenv.js --enable-source-maps ./build/ctl",
|
||||
"validate": "tsx scripts/validate.ts",
|
||||
"openapi": "tsx scripts/openapi.ts",
|
||||
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
||||
@@ -22,8 +22,8 @@
|
||||
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@aws-sdk/client-s3": "3.1025.0",
|
||||
"@aws-sdk/lib-storage": "3.1025.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -34,15 +34,15 @@
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^8.3.18",
|
||||
"@mantine/code-highlight": "^8.3.18",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^8.3.18",
|
||||
"@mantine/dropzone": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@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",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
@@ -50,8 +50,8 @@
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.1.1",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@smithy/node-http-handler": "^4.5.2",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.15.1",
|
||||
@@ -63,7 +63,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"detect-browser": "^5.3.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"devalue": "^5.7.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.8.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^3.7.1",
|
||||
"katex": "^0.16.42",
|
||||
"katex": "^0.16.45",
|
||||
"mantine-datatable": "^8.3.13",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.1.1",
|
||||
@@ -83,13 +83,12 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-window": "1.8.11",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.4.1",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.2",
|
||||
"vite": "^8.0.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
@@ -101,19 +100,18 @@
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint": "^10.2.0",
|
||||
"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-refresh": "^0.4.24",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
@@ -123,7 +121,8 @@
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
Generated
+1232
-1530
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Zipline" ADD COLUMN "featuresThumbnailsInstantaneous" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -58,9 +58,10 @@ model Zipline {
|
||||
featuresOauthRegistration Boolean @default(false)
|
||||
featuresDeleteOnMaxViews Boolean @default(true)
|
||||
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsEnabled Boolean @default(true)
|
||||
featuresThumbnailsNumberThreads Int @default(4)
|
||||
featuresThumbnailsFormat String @default("jpg")
|
||||
featuresThumbnailsInstantaneous Boolean @default(false)
|
||||
|
||||
featuresMetricsEnabled Boolean @default(true)
|
||||
featuresMetricsAdminOnly Boolean @default(false)
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function ReloadPage() {
|
||||
Why am I seeing this?
|
||||
</Button>
|
||||
|
||||
<Collapse in={view}>
|
||||
<Collapse expanded={view}>
|
||||
<GenericError
|
||||
title='Failed to fetch dynamically imported module'
|
||||
message='This error can occur when a new version of the app is deployed while you have the page open. Please reload the page to update to the latest version.'
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import GenericError from '../../error/GenericError';
|
||||
import { eitherTrue } from '@/lib/primitive';
|
||||
|
||||
export default function Login() {
|
||||
useTitle('Login');
|
||||
@@ -205,30 +206,45 @@ export default function Login() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider label='or' />
|
||||
{eitherTrue(
|
||||
config.mfa.passkeys && browserSupportsWebAuthn(),
|
||||
config.oauthEnabled.discord,
|
||||
config.oauthEnabled.github,
|
||||
config.oauthEnabled.google,
|
||||
config.oauthEnabled.oidc,
|
||||
) && (
|
||||
<>
|
||||
<Divider label='or' />
|
||||
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && <PasskeyAuthButton onAuthSuccess={mutate} />}
|
||||
{config.mfa.passkeys && browserSupportsWebAuthn() && (
|
||||
<PasskeyAuthButton onAuthSuccess={mutate} />
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton provider='GitHub' leftSection={<IconBrandGithubFilled size='1.1rem' />} />
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
<Group grow>
|
||||
{config.oauthEnabled.discord && (
|
||||
<ExternalAuthButton
|
||||
provider='Discord'
|
||||
leftSection={<IconBrandDiscordFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.github && (
|
||||
<ExternalAuthButton
|
||||
provider='GitHub'
|
||||
leftSection={<IconBrandGithubFilled size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.google && (
|
||||
<ExternalAuthButton
|
||||
provider='Google'
|
||||
leftSection={<IconBrandGoogleFilled stroke={4} size='1.1rem' />}
|
||||
/>
|
||||
)}
|
||||
{config.oauthEnabled.oidc && (
|
||||
<ExternalAuthButton provider='OIDC' leftSection={<IconCircleKeyFilled size='1.1rem' />} />
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Center>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type Response } from '@/lib/api/response';
|
||||
import { useQueryState } from '@/lib/client/hooks/useQueryState';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { FolderBreadcrumb } from '@/lib/folderHierarchy';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -16,7 +19,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconFolder, IconUpload } from '@tabler/icons-react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import { Link, Params, useLoaderData, useNavigate } from 'react-router-dom';
|
||||
|
||||
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
|
||||
@@ -58,6 +61,8 @@ function PublicFolderCard({ folder }: { folder: Partial<Folder> }) {
|
||||
);
|
||||
}
|
||||
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export function Component() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +86,21 @@ export function Component() {
|
||||
const breadcrumbs = buildBreadcrumbs();
|
||||
const children = (folder.children ?? []) as Partial<Folder>[];
|
||||
|
||||
const [perpage, setPerpage] = useState(15);
|
||||
const [page, setPage] = useQueryState('page', 1);
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container my='lg'>
|
||||
@@ -132,7 +152,7 @@ export function Component() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{(folder.files?.length ?? 0) > 0 && (
|
||||
{(visible.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Title order={3} mt='md' mb='sm'>
|
||||
Files
|
||||
@@ -145,7 +165,7 @@ export function Component() {
|
||||
}}
|
||||
spacing='md'
|
||||
>
|
||||
{folder.files?.map((file: any) => (
|
||||
{visible.map((file: any) => (
|
||||
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
|
||||
<DashboardFile file={file} reduce />
|
||||
</Suspense>
|
||||
@@ -159,6 +179,33 @@ export function Component() {
|
||||
This folder is empty.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group justify='space-between' align='center' mt='md'>
|
||||
<Text size='sm'>{`${from} - ${to} / ${totalRecords} files`}</Text>
|
||||
|
||||
<Group gap='sm'>
|
||||
<Select
|
||||
value={perpage.toString()}
|
||||
data={PER_PAGE_OPTIONS.map((val) => ({ value: val.toString(), label: `${val}` }))}
|
||||
onChange={(value) => {
|
||||
setPerpage(Number(value));
|
||||
setPage(1);
|
||||
}}
|
||||
w={80}
|
||||
size='xs'
|
||||
variant='filled'
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
total={cachedPages}
|
||||
size='sm'
|
||||
withControls
|
||||
withEdges
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -45,13 +45,6 @@ export default function ViewFileId() {
|
||||
|
||||
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
|
||||
|
||||
// Fix dates that were stringified during SSR
|
||||
if (file?.createdAt) (file as any).createdAt = new Date(file.createdAt);
|
||||
if (file?.updatedAt) (file as any).updatedAt = new Date(file.updatedAt);
|
||||
if (file?.deletesAt) (file as any).deletesAt = new Date(file.deletesAt);
|
||||
if (user?.createdAt) (user as any).createdAt = new Date(user.createdAt);
|
||||
if (user?.updatedAt) (user as any).updatedAt = new Date(user.updatedAt);
|
||||
|
||||
const [passwordValue, setPassword] = useState<string>('');
|
||||
const [passwordError, setPasswordError] = useState<string>('');
|
||||
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
|
||||
@@ -121,7 +114,7 @@ export default function ViewFileId() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Collapse in={detailsOpen}>
|
||||
<Collapse expanded={detailsOpen}>
|
||||
<Paper m='md' p='md' withBorder>
|
||||
{user?.view!.content && (
|
||||
<Typography>
|
||||
|
||||
+21
-28
@@ -8,6 +8,11 @@ import FourOhFour from './pages/404';
|
||||
import Login from './pages/auth/login';
|
||||
import Root from './Root';
|
||||
|
||||
const fourOhFourCatchall = {
|
||||
path: '*',
|
||||
Component: FourOhFour,
|
||||
};
|
||||
|
||||
export async function dashboardLoader() {
|
||||
try {
|
||||
const res = await fetch('/api/server/settings/web');
|
||||
@@ -33,17 +38,16 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
{ path: '*', Component: FourOhFour },
|
||||
fourOhFourCatchall,
|
||||
{
|
||||
path: '/auth',
|
||||
children: [
|
||||
{ path: 'login', Component: Login },
|
||||
{ path: 'register', lazy: () => import('./pages/auth/register') },
|
||||
{ path: 'auth/login', Component: Login },
|
||||
{ path: 'auth/register', lazy: () => import('./pages/auth/register') },
|
||||
{
|
||||
path: 'setup',
|
||||
path: 'auth/setup',
|
||||
lazy: () => import('./pages/auth/setup'),
|
||||
},
|
||||
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
|
||||
{ path: 'auth/tos', lazy: () => import('./pages/auth/tos') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -60,37 +64,26 @@ export const router = createBrowserRouter([
|
||||
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
|
||||
{ path: 'folders/*', lazy: () => import('./pages/dashboard/folders') },
|
||||
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
|
||||
{ path: 'upload/file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'upload/text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
|
||||
// admin routes
|
||||
{
|
||||
path: 'upload',
|
||||
children: [
|
||||
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
|
||||
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
loader: async () => {
|
||||
const res = await fetch('/api/user');
|
||||
if (!res.ok) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
if (!res.ok) return redirect('/auth/login');
|
||||
|
||||
const { user } = await res.json();
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
{ path: 'admin/users', lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: 'users',
|
||||
children: [
|
||||
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
|
||||
{
|
||||
path: ':id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
path: 'admin/users/:id/files',
|
||||
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+73
-50
@@ -47,10 +47,11 @@ import {
|
||||
IconUsersGroup,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, Outlet, useLoaderData, useLocation } from 'react-router-dom';
|
||||
import { Link, NavigateFunction, Outlet, useLoaderData, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { dashboardLoader } from '../client/routes';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
import { SETTINGS_EXTERNAL_LINKS } from './pages/serverSettings';
|
||||
|
||||
type NavLinks = {
|
||||
label: string;
|
||||
@@ -123,9 +124,15 @@ const navLinks: NavLinks[] = [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <IconAdjustments size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/settings',
|
||||
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
|
||||
if: (user) => user?.role === 'SUPERADMIN',
|
||||
href: '/dashboard/admin/settings',
|
||||
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
|
||||
label: name,
|
||||
icon,
|
||||
active: (path: string) => path === url,
|
||||
href: url,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
@@ -150,6 +157,66 @@ const navLinks: NavLinks[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const renderLinks = (
|
||||
links: NavLinks[],
|
||||
pathname: string,
|
||||
user: Response['/api/user']['user'],
|
||||
config: SafeConfig,
|
||||
navigate: NavigateFunction,
|
||||
) => {
|
||||
const visible = (link: NavLinks) => !link.if || link.if(user as Response['/api/user']['user'], config);
|
||||
|
||||
const active = (link: NavLinks): boolean => {
|
||||
if (!visible(link)) return false;
|
||||
if (link.active(pathname)) return true;
|
||||
|
||||
return (link.links || []).some((child) => active(child));
|
||||
};
|
||||
|
||||
return links.map((link) => {
|
||||
if (visible(link)) {
|
||||
const sublinks = link.links;
|
||||
const isActive = link.active(pathname);
|
||||
|
||||
if (!sublinks) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={isActive && !sublinks.some((child) => active(child))}
|
||||
defaultOpened={isActive || sublinks.some((child) => active(child))}
|
||||
onClick={(event) => {
|
||||
if (!link.href) return;
|
||||
event.preventDefault();
|
||||
navigate(link.href);
|
||||
}}
|
||||
>
|
||||
{renderLinks(sublinks, pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
@@ -158,6 +225,7 @@ export default function Layout() {
|
||||
const clipboard = useClipboard();
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogout();
|
||||
|
||||
const loaderData = useLoaderData<typeof dashboardLoader>();
|
||||
@@ -327,54 +395,9 @@ export default function Layout() {
|
||||
</Title>
|
||||
<Divider hiddenFrom='sm' />
|
||||
|
||||
{navLinks
|
||||
.filter((link) => !link.if || link.if(user as Response['/api/user']['user'], config))
|
||||
.map((link) => {
|
||||
if (!link.links) {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
active={location.pathname === link.href}
|
||||
component={Link}
|
||||
to={link.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
key={link.label}
|
||||
label={link.label}
|
||||
leftSection={link.icon}
|
||||
variant='light'
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
defaultOpened={link.active(location.pathname)}
|
||||
>
|
||||
{link.links
|
||||
.filter(
|
||||
(sublink) => !sublink.if || sublink.if(user as Response['/api/user']['user'], config),
|
||||
)
|
||||
.map((sublink) => (
|
||||
<NavLink
|
||||
key={sublink.label}
|
||||
label={sublink.label}
|
||||
leftSection={sublink.icon}
|
||||
rightSection={<IconChevronRight size='0.7rem' />}
|
||||
variant='light'
|
||||
active={location.pathname === sublink.href}
|
||||
component={Link}
|
||||
to={sublink.href || ''}
|
||||
prefetch='intent'
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<ScrollArea mah='calc(100vh - 200px)'>
|
||||
{renderLinks(navLinks, location.pathname, user as Response['/api/user']['user'], config, navigate)}
|
||||
</ScrollArea>
|
||||
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<VersionBadge />
|
||||
|
||||
@@ -396,7 +396,7 @@ export default function FileTable({
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Collapse in={selectedFiles.length > 0}>
|
||||
<Collapse expanded={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
Selections are saved across page changes
|
||||
@@ -487,7 +487,7 @@ export default function FileTable({
|
||||
</Collapse>
|
||||
|
||||
{modals && setModals && modals.idSearch && (
|
||||
<Collapse in={modals.idSearch}>
|
||||
<Collapse expanded={modals.idSearch}>
|
||||
<Paper withBorder p='sm' mt='sm'>
|
||||
<TextInput
|
||||
placeholder='Search by ID'
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
|
||||
{filesOpen ? '▼' : '▶'} {currentFolder.name}'s files{' '}
|
||||
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
|
||||
</Text>
|
||||
<Collapse in={filesOpen}>
|
||||
<Collapse expanded={filesOpen}>
|
||||
{view === 'grid' ? (
|
||||
<Paper withBorder p='sm'>
|
||||
<FilesGridView folderId={currentFolderId} />
|
||||
|
||||
+2
-2
@@ -139,7 +139,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} OS Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={osOpened}>
|
||||
<Collapse expanded={osOpened}>
|
||||
<HighlightCode language='json' code={JSON.stringify(export3.request.os, null, 2)} />
|
||||
</Collapse>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function Export3Details({ export3 }: { export3: Export3 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} Environment
|
||||
</Button>
|
||||
|
||||
<Collapse in={envOpened}>
|
||||
<Collapse expanded={envOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
+2
-2
@@ -195,7 +195,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} OS Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={osOpened}>
|
||||
<Collapse expanded={osOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
@@ -217,7 +217,7 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
|
||||
{envOpened ? 'Hide' : 'Show'} Environment
|
||||
</Button>
|
||||
|
||||
<Collapse in={envOpened}>
|
||||
<Collapse expanded={envOpened}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function Export4ImportSettings({
|
||||
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
|
||||
</Button>
|
||||
|
||||
<Collapse in={showSettings}>
|
||||
<Collapse expanded={showSettings}>
|
||||
<Paper withBorder>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
IconAdjustmentsHorizontalFilled,
|
||||
IconAppWindowFilled,
|
||||
IconArrowBack,
|
||||
IconAuth2fa,
|
||||
IconBrandDiscordFilled,
|
||||
IconClickFilled,
|
||||
IconClockPause,
|
||||
IconDatabase,
|
||||
IconExclamationMark,
|
||||
IconFiles,
|
||||
IconHttpPost,
|
||||
IconKeyFilled,
|
||||
IconLayoutGrid,
|
||||
IconLink,
|
||||
IconSubtask,
|
||||
IconTagsFilled,
|
||||
IconWorldPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { lazy, Suspense, useCallback } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
|
||||
const Core = lazy(() => import('./parts/Core'));
|
||||
const Chunks = lazy(() => import('./parts/Chunks'));
|
||||
@@ -20,116 +54,309 @@ const Tasks = lazy(() => import('./parts/Tasks'));
|
||||
const Urls = lazy(() => import('./parts/Urls'));
|
||||
const Website = lazy(() => import('./parts/Website'));
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return Array(17)
|
||||
.fill(null)
|
||||
.map((_, index) => <Skeleton key={index} height={280} animate />);
|
||||
}
|
||||
const InvalidSettingsSection = () => <Text>Invalid settings section</Text>;
|
||||
|
||||
const SETTINGS_COMPONENTS = {
|
||||
core: {
|
||||
component: Core,
|
||||
name: 'Core',
|
||||
key: 'core',
|
||||
desc: 'General server settings',
|
||||
Icon: IconDatabase,
|
||||
},
|
||||
chunks: {
|
||||
component: Chunks,
|
||||
name: 'Chunks',
|
||||
key: 'chunks',
|
||||
desc: 'Partial uploading',
|
||||
Icon: IconLayoutGrid,
|
||||
},
|
||||
discord: {
|
||||
component: Discord,
|
||||
name: 'Discord',
|
||||
key: 'discord',
|
||||
desc: 'Discord webhook integration',
|
||||
Icon: IconBrandDiscordFilled,
|
||||
},
|
||||
domains: {
|
||||
component: Domains,
|
||||
name: 'Domains',
|
||||
key: 'domains',
|
||||
desc: 'Add custom domains',
|
||||
Icon: IconWorldPlus,
|
||||
},
|
||||
features: {
|
||||
component: Features,
|
||||
name: 'Features',
|
||||
key: 'features',
|
||||
desc: 'Configure various features',
|
||||
Icon: IconAdjustmentsHorizontalFilled,
|
||||
},
|
||||
files: {
|
||||
component: Files,
|
||||
name: 'Files',
|
||||
key: 'files',
|
||||
desc: 'File uploading settings',
|
||||
Icon: IconFiles,
|
||||
},
|
||||
httpWebhook: {
|
||||
component: HttpWebhook,
|
||||
name: 'HTTP Webhook',
|
||||
key: 'httpWebhook',
|
||||
desc: 'Send POST requests to a URL on certain events',
|
||||
Icon: IconHttpPost,
|
||||
},
|
||||
invites: {
|
||||
component: Invites,
|
||||
name: 'Invites',
|
||||
key: 'invites',
|
||||
desc: 'Invite settings',
|
||||
Icon: IconTagsFilled,
|
||||
},
|
||||
mfa: {
|
||||
component: Mfa,
|
||||
name: 'Multi-Factor Authentication',
|
||||
key: 'mfa',
|
||||
desc: 'Enable or disable passkeys and TOTP authentication',
|
||||
Icon: IconAuth2fa,
|
||||
},
|
||||
oauth: {
|
||||
component: Oauth,
|
||||
name: 'OAuth',
|
||||
key: 'oauth',
|
||||
desc: 'Configure OAuth providers for authentication',
|
||||
Icon: IconKeyFilled,
|
||||
},
|
||||
pwa: {
|
||||
component: PWA,
|
||||
name: 'PWA',
|
||||
key: 'pwa',
|
||||
desc: 'Progressive Web App settings',
|
||||
Icon: IconAppWindowFilled,
|
||||
},
|
||||
ratelimit: {
|
||||
component: Ratelimit,
|
||||
name: 'Rate Limit',
|
||||
key: 'ratelimit',
|
||||
desc: 'Configure API rate limits',
|
||||
Icon: IconClockPause,
|
||||
},
|
||||
tasks: {
|
||||
component: Tasks,
|
||||
name: 'Tasks',
|
||||
key: 'tasks',
|
||||
desc: 'Background task intervals',
|
||||
Icon: IconSubtask,
|
||||
},
|
||||
urls: {
|
||||
component: Urls,
|
||||
name: 'URL Shortening',
|
||||
key: 'urls',
|
||||
desc: 'Configure URL shortening settings',
|
||||
Icon: IconLink,
|
||||
},
|
||||
website: {
|
||||
component: Website,
|
||||
name: 'Website',
|
||||
key: 'website',
|
||||
desc: 'Website related settings like title and description',
|
||||
Icon: IconClickFilled,
|
||||
},
|
||||
|
||||
// placeholder
|
||||
settings: {
|
||||
component: null,
|
||||
name: 'Server Settings',
|
||||
key: '',
|
||||
desc: '',
|
||||
Icon: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const SETTINGS_EXTERNAL_LINKS = Object.values(SETTINGS_COMPONENTS)
|
||||
.filter((setting) => setting.component !== null)
|
||||
.map((setting) => ({
|
||||
name: setting.name,
|
||||
url: `/dashboard/admin/settings/${setting.key}`,
|
||||
icon: setting.Icon ? <setting.Icon size='1rem' /> : <IconAdjustmentsHorizontalFilled size='1rem' />,
|
||||
}));
|
||||
|
||||
const SETTINGS_PART_KEYS = Object.keys(SETTINGS_COMPONENTS)
|
||||
.filter((key) => key !== 'settings')
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
export default function DashboardServerSettings() {
|
||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
const scrollToSetting = useMemo(() => {
|
||||
return (setting: string) => {
|
||||
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
|
||||
const parent = input?.parentElement?.parentElement;
|
||||
if (!input || !parent) return;
|
||||
const toSettingSection = useCallback((settingKey: string) => {
|
||||
const normalizedSetting = settingKey.toLowerCase();
|
||||
const matched = SETTINGS_PART_KEYS.find((key) => normalizedSetting.startsWith(key.toLowerCase()));
|
||||
|
||||
parent.style.transition = 'all 0.4s ease';
|
||||
parent.style.borderRadius = 'var(--mantine-radius-xs)';
|
||||
parent.style.outline = '2px solid var(--mantine-primary-color-filled)';
|
||||
parent.style.outlineOffset = 'var(--mantine-spacing-xs)';
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.length === 0) return;
|
||||
if (!entries[0].isIntersecting) return;
|
||||
|
||||
observer.disconnect();
|
||||
setTimeout(() => {
|
||||
parent.style.outline = '0 solid transparent';
|
||||
parent.style.outlineOffset = '0';
|
||||
parent.style.borderRadius = '0';
|
||||
}, 2000);
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
};
|
||||
return matched ?? 'settings';
|
||||
}, []);
|
||||
|
||||
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
const scrollToSetting = useCallback((setting: string) => {
|
||||
const input = document.querySelector<HTMLElement>(`[data-path="${setting}"]`);
|
||||
const parent = input?.parentElement?.parentElement;
|
||||
if (!input || !parent) return false;
|
||||
|
||||
scrollToSetting(setting);
|
||||
};
|
||||
parent.style.transition = 'all 0.4s ease';
|
||||
parent.style.borderRadius = 'var(--mantine-radius-xs)';
|
||||
parent.style.outline = '2px solid var(--mantine-primary-color-filled)';
|
||||
parent.style.outlineOffset = 'var(--mantine-spacing-xs)';
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.length === 0) return;
|
||||
if (!entries[0].isIntersecting) return;
|
||||
|
||||
observer.disconnect();
|
||||
setTimeout(() => {
|
||||
parent.style.outline = '0 solid transparent';
|
||||
parent.style.outlineOffset = '0';
|
||||
parent.style.borderRadius = '0';
|
||||
}, 2000);
|
||||
},
|
||||
{ threshold: 1.0 },
|
||||
);
|
||||
observer.observe(input);
|
||||
|
||||
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input.focus();
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const scrollToSettingWithRetry = useCallback(
|
||||
(setting: string, attemptsLeft = 18) => {
|
||||
const tryScroll = (remainingAttempts: number) => {
|
||||
if (scrollToSetting(setting)) return;
|
||||
if (remainingAttempts <= 0) return;
|
||||
|
||||
window.setTimeout(() => tryScroll(remainingAttempts - 1), 80);
|
||||
};
|
||||
|
||||
tryScroll(attemptsLeft);
|
||||
},
|
||||
[scrollToSetting],
|
||||
);
|
||||
|
||||
const onTamperedClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
const section = toSettingSection(setting);
|
||||
const url = `/dashboard/admin/settings/${section}`;
|
||||
|
||||
if (location.pathname === url) return scrollToSettingWithRetry(setting);
|
||||
|
||||
navigate(url);
|
||||
setTimeout(() => {
|
||||
scrollToSettingWithRetry(setting);
|
||||
}, 0);
|
||||
},
|
||||
[location.pathname, navigate, scrollToSettingWithRetry, toSettingSection],
|
||||
);
|
||||
|
||||
const pathPart = location.pathname.split('/')[4];
|
||||
let part = 'settings';
|
||||
if (pathPart && SETTINGS_COMPONENTS[pathPart as keyof typeof SETTINGS_COMPONENTS]) {
|
||||
part = pathPart;
|
||||
}
|
||||
|
||||
const setting = SETTINGS_COMPONENTS[part as keyof typeof SETTINGS_COMPONENTS];
|
||||
const SettingsComponent = setting.component ?? InvalidSettingsSection;
|
||||
|
||||
useTitle(setting.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
<Title order={1}>Server Settings</Title>
|
||||
<Group gap='sm' align='center' wrap='wrap'>
|
||||
{part !== 'settings' && (
|
||||
<ActionIcon component={Link} to='/dashboard/admin/settings' variant='outline'>
|
||||
<IconArrowBack size='1rem' />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Title order={1}>{setting.name}</Title>
|
||||
|
||||
{(data?.tampered?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
variant='outline'
|
||||
color={opened ? 'red' : 'blue'}
|
||||
size='xs'
|
||||
onClick={toggle}
|
||||
leftSection={<IconExclamationMark size='1rem' />}
|
||||
>
|
||||
{opened ? 'Hide' : 'Show'} Tampered ({data!.tampered.length})
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{(data?.tampered?.length ?? 0) > 0 && (
|
||||
<Alert color='red' title='Environment Variable Settings' mt='md'>
|
||||
<strong>{data!.tampered.length}</strong> setting{data!.tampered.length > 1 ? 's' : ''} have been set
|
||||
via environment variables, therefore any changes made to them on this page will not take effect
|
||||
unless the environment variable corresponding to the setting is removed. If you prefer using
|
||||
environment variables, you can ignore this message. Click{' '}
|
||||
<Anchor onClick={toggle} size='sm'>
|
||||
here
|
||||
</Anchor>{' '}
|
||||
to {opened ? 'close' : 'view'} the list of overridden settings.
|
||||
<Collapse in={opened} transitionDuration={200}>
|
||||
<ul>
|
||||
<Collapse expanded={opened} transitionDuration={180}>
|
||||
<Alert
|
||||
color='red'
|
||||
title='Environment Variable Settings'
|
||||
mb='md'
|
||||
icon={<IconExclamationMark size='1rem' />}
|
||||
>
|
||||
<Text size='sm' mb='xs'>
|
||||
These settings are controlled by environment variables:
|
||||
</Text>
|
||||
<Group gap='xs'>
|
||||
{data!.tampered.map((setting) => (
|
||||
<li key={setting}>
|
||||
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
|
||||
</li>
|
||||
<Anchor key={setting} onClick={(e) => onTamperedClick(e, setting)} size='sm'>
|
||||
{setting}
|
||||
</Anchor>
|
||||
))}
|
||||
</ul>
|
||||
</Collapse>
|
||||
</Alert>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
{error ? (
|
||||
<div>Error loading server settings</div>
|
||||
) : (
|
||||
<Suspense fallback={<SettingsSkeleton />}>
|
||||
<Core swr={{ data, isLoading }} />
|
||||
<Chunks swr={{ data, isLoading }} />
|
||||
<Tasks swr={{ data, isLoading }} />
|
||||
<Mfa swr={{ data, isLoading }} />
|
||||
|
||||
<Features swr={{ data, isLoading }} />
|
||||
<Files swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Urls swr={{ data, isLoading }} />
|
||||
<Invites swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
|
||||
<Ratelimit swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<Website swr={{ data, isLoading }} />
|
||||
<PWA swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
<Oauth swr={{ data, isLoading }} />
|
||||
|
||||
<HttpWebhook swr={{ data, isLoading }} />
|
||||
|
||||
<Domains swr={{ data, isLoading }} />
|
||||
{part !== 'settings' ? (
|
||||
<Box my='sm' p='xs' pos='relative' bdrs='lg'>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box h={400} pos='relative'>
|
||||
<LoadingOverlay visible bdrs='md' />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<SettingsComponent swr={{ data, isLoading }} />
|
||||
</Suspense>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack mt='md' gap='md'>
|
||||
{Object.entries(SETTINGS_COMPONENTS)
|
||||
.filter(([key]) => key !== 'settings')
|
||||
.map(([k, { key, Icon, name, desc }]) => (
|
||||
<Anchor
|
||||
key={k}
|
||||
component={Link}
|
||||
to={`/dashboard/admin/settings/${key}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md'>
|
||||
<ActionIcon variant='filled' radius='md' size='xl'>
|
||||
{Icon ? <Icon size='1.75rem' /> : <IconAdjustmentsHorizontalFilled size='1.75rem' />}
|
||||
</ActionIcon>
|
||||
|
||||
<Stack mt='md' gap='md'>
|
||||
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||
</Stack>
|
||||
<div>
|
||||
<Title order={4}>{name}</Title>
|
||||
<Text c='dimmed'>{desc}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -40,20 +40,17 @@ export default function Chunks({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Chunks</Title>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} bdrs='md' />
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Enable Chunks'
|
||||
description='Enable chunked uploads.'
|
||||
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Chunks'
|
||||
description='Enable chunked uploads.'
|
||||
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
label='Max Chunk Size'
|
||||
description='Maximum size of an upload before it is split into chunks.'
|
||||
@@ -69,12 +66,12 @@ export default function Chunks({
|
||||
disabled={!form.values.chunksEnabled}
|
||||
{...form.getInputProps('chunksSize')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, Switch, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -52,13 +52,11 @@ export default function Core({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Core</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='Return HTTPS URLs'
|
||||
@@ -85,12 +83,12 @@ export default function Core({
|
||||
placeholder='/tmp/zipline'
|
||||
{...form.getInputProps('coreTempDirectory')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,14 +170,11 @@ export default function Discord({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Discord Webhook</Title>
|
||||
|
||||
<form onSubmit={formMain.onSubmit(onSubmitMain)}>
|
||||
<TextInput
|
||||
mt='md'
|
||||
label='Webhook URL'
|
||||
description='The Discord webhook URL to send notifications to'
|
||||
placeholder='https://discord.com/api/webhooks/...'
|
||||
@@ -248,7 +245,7 @@ export default function Discord({
|
||||
{...formOnUpload.getInputProps('discordOnUploadEmbed', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Collapse in={formOnUpload.values.discordOnUploadEmbed}>
|
||||
<Collapse expanded={formOnUpload.values.discordOnUploadEmbed}>
|
||||
<Paper withBorder p='sm' mt='md'>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
@@ -351,7 +348,7 @@ export default function Discord({
|
||||
{...formOnShorten.getInputProps('discordOnShortenEmbed', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Collapse in={formOnShorten.values.discordOnShortenEmbed}>
|
||||
<Collapse expanded={formOnShorten.values.discordOnShortenEmbed}>
|
||||
<Paper withBorder p='sm' mt='md'>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
@@ -399,6 +396,6 @@ export default function Discord({
|
||||
</form>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { ActionIcon, Group, LoadingOverlay, Paper, Table, Text, TextInput, Title } from '@mantine/core';
|
||||
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';
|
||||
@@ -39,7 +39,7 @@ export default function Domains({
|
||||
}
|
||||
}
|
||||
|
||||
const addDomain = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
const addDomain = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const domain = form.values.domains.trim();
|
||||
@@ -55,23 +55,20 @@ export default function Domains({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading || submitting} />
|
||||
|
||||
<Title order={2}>Domains</Title>
|
||||
|
||||
<form onSubmit={addDomain}>
|
||||
<Group mt='md' align='flex-end'>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
flex={1}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
<ActionIcon type='submit' color='blue' size='lg' variant='filled' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<TextInput
|
||||
description='Enter a domain name'
|
||||
placeholder='example.com'
|
||||
rightSection={
|
||||
<ActionIcon type='submit' variant='transparent' disabled={submitting}>
|
||||
<IconPlus size='1.25rem' />
|
||||
</ActionIcon>
|
||||
}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{domains.length > 0 ? (
|
||||
@@ -106,6 +103,6 @@ export default function Domains({
|
||||
No domains added yet.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
@@ -35,6 +35,7 @@ export default function Features({
|
||||
featuresThumbnailsEnabled: true,
|
||||
featuresThumbnailsNumberThreads: 4,
|
||||
featuresThumbnailsFormat: 'jpg',
|
||||
featuresThumbnailsInstantaneous: false,
|
||||
featuresMetricsEnabled: true,
|
||||
featuresMetricsAdminOnly: false,
|
||||
featuresMetricsShowUserSpecific: true,
|
||||
@@ -61,6 +62,7 @@ export default function Features({
|
||||
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,
|
||||
@@ -70,13 +72,11 @@ export default function Features({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Features</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Image Compression'
|
||||
description='Allows the ability for users to compress images.'
|
||||
@@ -130,12 +130,21 @@ export default function Features({
|
||||
description='Shows metrics specific to each user, for all users.'
|
||||
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
||||
/>
|
||||
<div />
|
||||
<Switch
|
||||
label='Enable Thumbnails'
|
||||
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||
{...form.getInputProps('featuresThumbnailsEnabled', { 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'
|
||||
@@ -157,19 +166,20 @@ export default function Features({
|
||||
{...form.getInputProps('featuresThumbnailsFormat')}
|
||||
/>
|
||||
|
||||
<div />
|
||||
<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='zipline-version.diced.sh' target='_blank'>
|
||||
<Anchor size='xs' href='https://zipline-version.diced.sh' target='_blank'>
|
||||
https://zipline-version.diced.sh
|
||||
</Anchor>
|
||||
. Visit the{' '}
|
||||
@@ -182,12 +192,12 @@ export default function Features({
|
||||
placeholder='https://zipline-version.diced.sh/'
|
||||
{...form.getInputProps('featuresVersionAPI')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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';
|
||||
@@ -117,13 +107,23 @@ export default function Files({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Files</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<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' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for file uploads. Requires a server restart.'
|
||||
@@ -139,18 +139,6 @@ export default function Files({
|
||||
{...form.getInputProps('filesLength')}
|
||||
/>
|
||||
|
||||
<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' })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label='Default Format'
|
||||
description='The default format to use for file names.'
|
||||
@@ -228,12 +216,12 @@ export default function Files({
|
||||
min={1}
|
||||
{...form.getInputProps('filesMaxFilesPerUpload')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, Stack, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -47,13 +47,11 @@ export default function HttpWebhook({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>HTTP Webhooks</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='On Upload'
|
||||
description='The URL to send a POST request to when a file is uploaded.'
|
||||
@@ -67,12 +65,12 @@ export default function HttpWebhook({
|
||||
placeholder='https://example.com/shorten'
|
||||
{...form.getInputProps('httpWebhookOnShorten')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Stack, Switch } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -38,13 +38,11 @@ export default function Invites({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative' h='100%'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Invites</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Invites'
|
||||
description='Enable the use of invite links to register new users.'
|
||||
@@ -59,12 +57,12 @@ export default function Invites({
|
||||
max={64}
|
||||
{...form.getInputProps('invitesLength')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||
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';
|
||||
@@ -41,13 +41,11 @@ export default function Mfa({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Multi-Factor Authentication</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<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.'
|
||||
@@ -68,6 +66,8 @@ export default function Mfa({
|
||||
{...form.getInputProps('mfaPasskeysOrigin')}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
label='Enable TOTP'
|
||||
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
|
||||
@@ -79,12 +79,12 @@ export default function Mfa({
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('mfaTotpIssuer')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function Oauth({
|
||||
@@ -124,21 +125,22 @@ export default function Oauth({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>OAuth</Title>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
For OAuth to work, the "OAuth Registration" setting must be enabled in the Features section.
|
||||
If you have issues, try restarting Zipline after saving.
|
||||
<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'>
|
||||
Features
|
||||
</Anchor>{' '}
|
||||
section. If you have issues, try restarting Zipline after saving.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Bypass Local Login'
|
||||
description='Skips the local login page and redirects to the OAuth provider, this only works with one provider enabled.'
|
||||
description='Skips the local login page and redirects to the OAuth provider, this will only work with one provider enabled.'
|
||||
{...form.getInputProps('oauthBypassLocalLogin', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
@@ -147,35 +149,33 @@ export default function Oauth({
|
||||
description='Disables registration and only allows login with OAuth, existing users can link providers for example.'
|
||||
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://discord.com/developers/applications' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
Discord
|
||||
</Title>
|
||||
</Anchor>
|
||||
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||
<TextInput
|
||||
label='Discord Allowed IDs'
|
||||
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Denied IDs'
|
||||
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Discord Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthDiscordRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Paper withBorder p='sm'>
|
||||
<Anchor href='https://console.developers.google.com/' target='_blank'>
|
||||
<Title order={4} mb='sm'>
|
||||
@@ -207,29 +207,29 @@ export default function Oauth({
|
||||
{...form.getInputProps('oauthGithubRedirectUri')}
|
||||
/>
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder p='sm' my='md'>
|
||||
<Title order={4}>OpenID Connect</Title>
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={4}>OpenID Connect</Title>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
|
||||
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
|
||||
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
|
||||
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
|
||||
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
|
||||
<TextInput
|
||||
label='OIDC Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthOidcRedirectUri')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
|
||||
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
|
||||
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
|
||||
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
|
||||
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
|
||||
<TextInput
|
||||
label='OIDC Redirect URL'
|
||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||
{...form.getInputProps('oauthOidcRedirectUri')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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';
|
||||
@@ -73,24 +62,21 @@ export default function PWA({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative' h='100%'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>PWA</Title>
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
Refresh the page after enabling PWA to see any changes.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Switch
|
||||
mt='md'
|
||||
label='PWA Enabled'
|
||||
description='Allow users to install the Zipline PWA on their devices.'
|
||||
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='PWA Enabled'
|
||||
description='Allow users to install the Zipline PWA on their devices.'
|
||||
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title for the PWA'
|
||||
@@ -125,8 +111,7 @@ export default function PWA({
|
||||
placeholder='#ffffff'
|
||||
{...form.getInputProps('pwaBackgroundColor')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
</Stack>
|
||||
<Group mt='md'>
|
||||
<Button type='submit' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
@@ -136,6 +121,6 @@ export default function PWA({
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import {
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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';
|
||||
@@ -78,17 +68,15 @@ export default function Ratelimit({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Ratelimit</Title>
|
||||
|
||||
<Text c='dimmed' size='sm'>
|
||||
<Text size='sm' c='dimmed' mb='md'>
|
||||
All options require a restart to take effect.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<Switch
|
||||
label='Enable Ratelimit'
|
||||
description='Enable ratelimiting for the server.'
|
||||
@@ -123,12 +111,12 @@ export default function Ratelimit({
|
||||
placeholder='192.168.1.1, 127.0.0.1, 0.0.0.0'
|
||||
{...form.getInputProps('ratelimitAllowList')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
|
||||
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';
|
||||
@@ -43,17 +43,15 @@ export default function Tasks({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>Tasks</Title>
|
||||
|
||||
<Text c='dimmed' size='sm'>
|
||||
All options require a restart to take effect.
|
||||
<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>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Delete Files Interval'
|
||||
description='How often to check and delete expired files.'
|
||||
@@ -88,12 +86,12 @@ export default function Tasks({
|
||||
placeholder='1d'
|
||||
{...form.getInputProps('tasksCleanThumbnailsInterval')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||
import { Button, LoadingOverlay, NumberInput, Stack, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
@@ -35,13 +35,11 @@ export default function Urls({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm' pos='relative'>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<Title order={2}>URL Shortener</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for short URLs. Requires a server restart.'
|
||||
@@ -57,12 +55,12 @@ export default function Urls({
|
||||
max={64}
|
||||
{...form.getInputProps('urlsLength')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Button, Grid, JsonInput, Paper, Switch, TextInput, Title } from '@mantine/core';
|
||||
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';
|
||||
@@ -13,7 +13,7 @@ const defaultExternalLinks = [
|
||||
},
|
||||
{
|
||||
name: 'Documentation',
|
||||
url: 'https://zipline.diced.tech',
|
||||
url: 'https://zipline.diced.sh',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,110 +98,88 @@ export default function Website({
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Website</Title>
|
||||
<>
|
||||
<LoadingOverlay visible={isLoading} />
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
{/* <SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'> */}
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title of the website in browser tabs and at the top.'
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('websiteTitle')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Stack gap='lg'>
|
||||
<TextInput
|
||||
label='Title'
|
||||
description='The title of the website in browser tabs and at the top.'
|
||||
placeholder='Zipline'
|
||||
{...form.getInputProps('websiteTitle')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<JsonInput
|
||||
label='External Links'
|
||||
description='The external links to show in the footer. This must be valid JSON.'
|
||||
formatOnBlur
|
||||
minRows={1}
|
||||
maxRows={7}
|
||||
autosize
|
||||
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
|
||||
{...form.getInputProps('websiteExternalLinks')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<TextInput
|
||||
label='Login Background'
|
||||
description='The URL to use for the login background.'
|
||||
placeholder='https://example.com/background.png'
|
||||
{...form.getInputProps('websiteLoginBackground')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Login Background'
|
||||
description='The URL to use for the login background.'
|
||||
placeholder='https://example.com/background.png'
|
||||
{...form.getInputProps('websiteLoginBackground')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Switch
|
||||
label='Login Background Blur'
|
||||
description='Whether to blur the login background.'
|
||||
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Switch
|
||||
label='Login Background Blur'
|
||||
description='Whether to blur the login background.'
|
||||
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<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')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={12}>
|
||||
<TextInput
|
||||
label='Default Theme'
|
||||
description='The default theme to use for the website.'
|
||||
placeholder='system'
|
||||
{...form.getInputProps('websiteThemeDefault')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<TextInput
|
||||
label='Default Theme'
|
||||
description='The default theme to use for the website.'
|
||||
placeholder='system'
|
||||
{...form.getInputProps('websiteThemeDefault')}
|
||||
/>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<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')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<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>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<ty
|
||||
mutate('/api/server/settings', data);
|
||||
mutate('/api/server/settings/web');
|
||||
mutate('/api/server/public');
|
||||
navigate('/dashboard/admin/settings', { replace: true });
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function DashboardSettings() {
|
||||
config.oauthEnabled.oidc,
|
||||
) && <SettingsOAuth />}
|
||||
|
||||
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys) && <SettingsMfa />}
|
||||
{eitherTrue(config.mfa.totp.enabled, config.mfa.passkeys.enabled) && <SettingsMfa />}
|
||||
|
||||
<SettingsExports />
|
||||
<SettingsGenerators />
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function SettingsDashboard() {
|
||||
label='Default Domain'
|
||||
description='Set the default domain used for copied links anywhere in the dashboard. Leave blank or select "Default domain" to use the current domain that serves the dashboard.'
|
||||
value={settings.domain}
|
||||
onChange={(value) => update('domain', value ?? '')}
|
||||
onChange={(value) => update('domain', (value as string) ?? '')}
|
||||
/>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { findProvider } from '@/lib/oauth/providers';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { darken } from '@/lib/theme/color';
|
||||
import type { OAuthProviderType } from '@/prisma/client';
|
||||
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
|
||||
@@ -68,6 +68,12 @@ function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked
|
||||
'--z-bol-color': darken(t.colors?.[provider.toLowerCase()]?.[0] ?? '', 0.2, t),
|
||||
},
|
||||
className: !linked ? styles.button : undefined,
|
||||
styles: {
|
||||
label: {
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return linked ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Progress,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
rem,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
@@ -193,7 +194,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
<Collapse in={progress.percent > 0 && progress.percent < 100}>
|
||||
<Collapse expanded={progress.percent > 0 && progress.percent < 100}>
|
||||
{progress.percent > 0 && progress.percent < 100 && (
|
||||
<Progress.Root my='sm' size='xl'>
|
||||
<Progress.Section value={progress.percent} animated>
|
||||
@@ -203,7 +204,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
)}
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={progress.speed > 0 && progress.remaining > 0}>
|
||||
<Collapse expanded={progress.speed > 0 && progress.remaining > 0}>
|
||||
<Paper withBorder p='xs' radius='sm'>
|
||||
<Text ta='center' size='sm'>
|
||||
{bytes(progress.speed)}/s, {humanizeDuration(progress.remaining)} remaining
|
||||
@@ -211,7 +212,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
</Paper>
|
||||
</Collapse>
|
||||
|
||||
<Collapse in={progress.percent === 100}>
|
||||
<Collapse expanded={progress.percent === 100}>
|
||||
<Paper withBorder p='xs' radius='sm'>
|
||||
<Text ta='center' size='sm' c='yellow' fw={500}>
|
||||
Finalizing upload(s)...
|
||||
@@ -244,14 +245,16 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
<Button
|
||||
size='compact-sm'
|
||||
variant='subtle'
|
||||
disabled={dropLoading}
|
||||
onClick={() => setVisibleCount(files.length)}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
<Tooltip label='This may cause performance issues if there are a lot of files' hidden={dropLoading}>
|
||||
<Button
|
||||
size='compact-sm'
|
||||
variant='subtle'
|
||||
disabled={dropLoading}
|
||||
onClick={() => setVisibleCount(files.length)}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
</>
|
||||
}
|
||||
value={options.overrides_returnDomain ?? ''}
|
||||
onChange={(value) => setOption('overrides_returnDomain', value || null)}
|
||||
onChange={(value) => setOption('overrides_returnDomain', (value as string) || null)}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
portalProps: {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||
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 { useEffect, useMemo, useState } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import './HighlightCode.theme.scss';
|
||||
import * as sanitize from 'isomorphic-dompurify';
|
||||
import './HighlightCode.theme.scss';
|
||||
|
||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||
const { pathname } = useLocation();
|
||||
@@ -20,37 +19,33 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
import('highlight.js').then((mod) => setHljs(mod.default || mod));
|
||||
}, []);
|
||||
|
||||
const lines = useMemo(() => code.split('\n'), [code]);
|
||||
const visible = expanded || noClamp ? lines.length : Math.min(lines.length, 50);
|
||||
const expandable = !noClamp && lines.length > 50;
|
||||
const cleanedCode = sanitize.sanitize(code, { USE_PROFILES: { html: true } });
|
||||
const lines = cleanedCode.split('\n');
|
||||
const isExpandable = !noClamp && lines.length > 50;
|
||||
|
||||
const totalCount = isExpandable && !expanded ? 50 : lines.length;
|
||||
const estimatedHeight = Math.min(totalCount * 24, 400);
|
||||
|
||||
const lang = useMemo(() => {
|
||||
if (!hljs) return 'plaintext';
|
||||
if (hljs.getLanguage(language)) return language;
|
||||
|
||||
return 'plaintext';
|
||||
return hljs.getLanguage(language) ? language : 'plaintext';
|
||||
}, [hljs, language]);
|
||||
|
||||
const hlLines = useMemo(() => {
|
||||
if (!hljs) return lines;
|
||||
|
||||
return lines.map(
|
||||
(line) =>
|
||||
hljs.highlight(line, {
|
||||
language: lang,
|
||||
}).value,
|
||||
);
|
||||
return lines.map((line) => hljs.highlight(line || ' ', { language: lang }).value);
|
||||
}, [lines, hljs, lang]);
|
||||
|
||||
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
|
||||
const rowRenderer = (index: number) => (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
whiteSpace: 'pre',
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: theme.fontFamilyMonospace,
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: '1.5',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -69,16 +64,14 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
|
||||
<code
|
||||
className='theme hljs'
|
||||
style={{ flex: 1, fontSize: '0.8rem' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitize.sanitize(hlLines[index], { USE_PROFILES: { html: true } }),
|
||||
}}
|
||||
style={{ flex: 1, padding: 0, background: 'none', alignSelf: 'center' }}
|
||||
dangerouslySetInnerHTML={{ __html: hlLines[index] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper withBorder p='xs' my='md' pos='relative'>
|
||||
<Paper withBorder p='xs' my='md' pos='relative' style={{ overflow: 'hidden' }}>
|
||||
<CopyButton value={code}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon
|
||||
@@ -86,40 +79,39 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
variant='outline'
|
||||
color={copied ? 'green' : 'gray'}
|
||||
size='md'
|
||||
style={{ zIndex: 4, position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
style={{ zIndex: 10, position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{!copied ? (
|
||||
<IconClipboardCopy size='1rem' />
|
||||
) : (
|
||||
{copied ? (
|
||||
<IconCheck color={theme.colors.green[4]} size='1rem' />
|
||||
) : (
|
||||
<IconClipboardCopy size='1rem' />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
|
||||
{noClamp ? (
|
||||
<ScrollArea type='auto' offsetScrollbars={false}>
|
||||
<div>
|
||||
{hlLines.map((_, index) => (
|
||||
<Row key={index} index={index} style={{}} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<ScrollArea type='auto' offsetScrollbars={false} style={{ maxHeight: 400 }}>
|
||||
<List height={400} width='100%' itemCount={visible} itemSize={20} overscanCount={10}>
|
||||
{Row}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div style={{ height: noClamp && (expanded || !isExpandable) ? 'auto' : estimatedHeight }}>
|
||||
<Virtuoso
|
||||
style={{ height: '100%' }}
|
||||
totalCount={totalCount}
|
||||
itemContent={rowRenderer}
|
||||
initialItemCount={30}
|
||||
increaseViewportBy={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expandable && (
|
||||
{isExpandable && (
|
||||
<Button
|
||||
variant='light'
|
||||
size='compact-sm'
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
|
||||
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0.5rem',
|
||||
right: '0.5rem',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Show Less' : `Show More (${lines.length - 50} more lines)`}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// --require ./src/dotenv.js
|
||||
// loads environment variables from a .env file into process.env on startup
|
||||
try {
|
||||
process.loadEnvFile('.env');
|
||||
} catch {}
|
||||
@@ -69,10 +69,11 @@ export async function uploadFiles(
|
||||
|
||||
notifications.show({
|
||||
id: 'upload',
|
||||
title: 'Uploading files',
|
||||
message: `Uploading ${files.length} file${files.length === 1 ? '' : 's'} in ${batches} request${
|
||||
batches === 1 ? '' : 's'
|
||||
}`,
|
||||
title: `Preparing file${files.length > 1 ? 's' : ''}`,
|
||||
message:
|
||||
batches > 1
|
||||
? `Uploading ${files.length} file${files.length > 1 ? 's' : ''} in ${batches} batche${batches > 1 ? 's' : ''}`
|
||||
: `Uploading ${files.length} file${files.length > 1 ? 's' : ''}`,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
@@ -132,13 +133,14 @@ export async function uploadFiles(
|
||||
const batchBytes = batchFiles.reduce((acc, file) => acc + file.size, 0);
|
||||
|
||||
notifications.update({
|
||||
id: 'upload',
|
||||
title: 'Uploading files',
|
||||
message: `Uploading batch ${batchIndex + 1}/${batches} (${batchFiles.length} file${
|
||||
batchFiles.length === 1 ? '' : 's'
|
||||
})`,
|
||||
title:
|
||||
batches > 1
|
||||
? `Uploading batch ${batchIndex + 1}/${batches}`
|
||||
: `Uploading file${batchFiles.length > 1 ? 's' : ''}`,
|
||||
message: `${batchFiles.length} file${batchFiles.length > 1 ? 's' : ''}`,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
id: 'upload',
|
||||
});
|
||||
|
||||
const res = await uploadBatch(batchFiles, completedBytes);
|
||||
@@ -150,7 +152,7 @@ export async function uploadFiles(
|
||||
|
||||
notifications.update({
|
||||
id: 'upload',
|
||||
title: 'Uploaded files',
|
||||
title: 'Upload complete',
|
||||
message: `Uploaded ${files.length} file${files.length === 1 ? '' : 's'}`,
|
||||
color: 'green',
|
||||
icon: <IconFileUpload size='1rem' />,
|
||||
|
||||
@@ -121,7 +121,7 @@ export function showUploadModal(
|
||||
};
|
||||
|
||||
modals.open({
|
||||
title: 'Uploaded files',
|
||||
title: `Uploaded ${files.length} file${files.length > 1 ? 's' : ''}`,
|
||||
size: 'auto',
|
||||
children: (
|
||||
<>
|
||||
|
||||
@@ -47,6 +47,7 @@ export const DATABASE_TO_PROP = {
|
||||
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
|
||||
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
|
||||
featuresThumbnailsFormat: 'features.thumbnails.format',
|
||||
featuresThumbnailsInstantaneous: 'features.thumbnails.instantaneous',
|
||||
|
||||
featuresMetricsEnabled: 'features.metrics.enabled',
|
||||
featuresMetricsAdminOnly: 'features.metrics.adminOnly',
|
||||
|
||||
@@ -80,6 +80,7 @@ export const ENVS = [
|
||||
env('features.thumbnails.enabled', 'FEATURES_THUMBNAILS_ENABLED', 'boolean', true),
|
||||
env('features.thumbnails.num_threads', 'FEATURES_THUMBNAILS_NUM_THREADS', 'number', true),
|
||||
env('features.thumbnails.format', 'FEATURES_THUMBNAILS_FORMAT', 'string', true),
|
||||
env('features.thumbnails.instantaneous', 'FEATURES_THUMBNAILS_INSTANTANEOUS', 'boolean', true),
|
||||
|
||||
env('features.metrics.enabled', 'FEATURES_METRICS_ENABLED', 'boolean', true),
|
||||
env('features.metrics.adminOnly', 'FEATURES_METRICS_ADMIN_ONLY', 'boolean', true),
|
||||
|
||||
@@ -204,6 +204,7 @@ export const schema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
num_threads: z.number().default(4),
|
||||
format: z.enum(['jpg', 'png', 'webp']).default('jpg'),
|
||||
instantaneous: z.boolean().default(false),
|
||||
}),
|
||||
metrics: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
|
||||
@@ -63,6 +63,8 @@ export class S3Datasource extends Datasource {
|
||||
keepAlive: true,
|
||||
}),
|
||||
}),
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
|
||||
this.ensureReadWriteAccess();
|
||||
|
||||
+7
-3
@@ -4,10 +4,14 @@ const MAX = 256 - (256 % CHARSET_LENGTH);
|
||||
|
||||
function getRandomValues(array: Uint8Array) {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
return crypto.getRandomValues(array);
|
||||
// TODO: remove any cast when the types are fixed...
|
||||
return crypto.getRandomValues(<any>array);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('crypto').webcrypto.getRandomValues(array);
|
||||
console.error(
|
||||
'No secure random number generator available. Please use node@22+ and a supported platform.',
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { ZIPLINE_SSR_PROP } from './constants';
|
||||
import { uneval } from 'devalue';
|
||||
|
||||
function strip(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return obj;
|
||||
|
||||
if (Array.isArray(obj)) return obj.map((item) => strip(item));
|
||||
|
||||
const stripped: Record<string, any> = {};
|
||||
for (const key of Object.keys(obj)) stripped[key] = strip(obj[key]);
|
||||
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function createZiplineSsr(data: any) {
|
||||
return `<script>window.${ZIPLINE_SSR_PROP} = ${JSON.stringify(data).replace(/</g, '\u003c')};</script>`;
|
||||
return `<script>window.${ZIPLINE_SSR_PROP} = ${uneval(strip(data))};</script>`;
|
||||
}
|
||||
|
||||
@@ -169,4 +169,8 @@ export class Tasks {
|
||||
|
||||
return this.tasks[len - 1] as WorkerTask<Data>;
|
||||
}
|
||||
|
||||
public workersBy(starting: string): WorkerTask[] {
|
||||
return this.tasks.filter((x) => 'worker' in x && x.id.startsWith(starting)) as WorkerTask[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { IntervalTask, WorkerTask } from '..';
|
||||
|
||||
export function runThumbnailWorkers(workers: WorkerTask[], files: string[]) {
|
||||
const thumbToWorker: { id: string; worker: number }[] = [];
|
||||
|
||||
let workerIndex = 0;
|
||||
for (const file of files) {
|
||||
thumbToWorker.push({
|
||||
id: file,
|
||||
worker: workerIndex,
|
||||
});
|
||||
|
||||
workerIndex = (workerIndex + 1) % workers.length;
|
||||
}
|
||||
|
||||
const ids = workers.map((_, i) => thumbToWorker.filter((x) => x.worker === i).map((x) => x.id));
|
||||
|
||||
for (let i = 0; i !== workers.length; ++i) {
|
||||
if (!ids[i].length) continue;
|
||||
|
||||
workers[i].worker!.postMessage({
|
||||
type: 0,
|
||||
data: ids[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function thumbnails(prisma: typeof globalThis.__db__) {
|
||||
return async function (this: IntervalTask, rerun = false) {
|
||||
const thumbnailWorkers = this.tasks.tasks.filter(
|
||||
@@ -23,27 +48,9 @@ export default function thumbnails(prisma: typeof globalThis.__db__) {
|
||||
|
||||
this.logger.debug(`found ${thumbnailNeeded.length} files that need thumbnails`);
|
||||
|
||||
const thumbToWorker: { id: string; worker: number }[] = [];
|
||||
|
||||
let workerIndex = 0;
|
||||
for (const file of thumbnailNeeded) {
|
||||
thumbToWorker.push({
|
||||
id: file.id,
|
||||
worker: workerIndex,
|
||||
});
|
||||
|
||||
workerIndex = (workerIndex + 1) % thumbnailWorkers.length;
|
||||
}
|
||||
|
||||
const ids = thumbnailWorkers.map((_, i) => thumbToWorker.filter((x) => x.worker === i).map((x) => x.id));
|
||||
|
||||
for (let i = 0; i !== thumbnailWorkers.length; ++i) {
|
||||
if (!ids[i].length) continue;
|
||||
|
||||
thumbnailWorkers[i].worker!.postMessage({
|
||||
type: 0,
|
||||
data: ids[i],
|
||||
});
|
||||
}
|
||||
runThumbnailWorkers(
|
||||
thumbnailWorkers,
|
||||
thumbnailNeeded.map((x) => x.id),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,3 +21,26 @@ export function zValidatePath(val: string | undefined, ctx: z.RefinementCtx) {
|
||||
export const zStringTrimmed = z.string().trim().min(1);
|
||||
|
||||
export const zQsBoolean = z.enum(['true', 'false']).transform((val) => val === 'true');
|
||||
|
||||
export const paginationQs = z.object({
|
||||
page: z.coerce.number(),
|
||||
perpage: z.coerce.number().default(15),
|
||||
filter: z.enum(['dashboard', 'none', 'all']).optional().default('none'),
|
||||
favorite: zQsBoolean.default(false).optional(),
|
||||
sortBy: z
|
||||
.enum([
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletesAt',
|
||||
'name',
|
||||
'originalName',
|
||||
'size',
|
||||
'type',
|
||||
'views',
|
||||
'favorite',
|
||||
])
|
||||
.optional()
|
||||
.default('createdAt'),
|
||||
order: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
});
|
||||
|
||||
@@ -41,7 +41,9 @@ async function vitePlugin(fastify: FastifyInstance) {
|
||||
...reservedRoutes.filter((x) => x !== '/dashboard' && x !== '/auth' && x !== '/r'),
|
||||
config.files.route,
|
||||
config.urls.route,
|
||||
].some((route) => url.startsWith(route));
|
||||
]
|
||||
.filter((url) => url.trim() !== '/')
|
||||
.some((route) => url.startsWith(route));
|
||||
|
||||
if (reserved) return;
|
||||
|
||||
|
||||
@@ -55,10 +55,12 @@ const zMs = zStringTrimmed.refine(
|
||||
);
|
||||
const zBytes = zStringTrimmed.refine((value) => bytes(value) > 0, 'Value must be greater than 0');
|
||||
|
||||
const zIntervalMs = zMs.refine(
|
||||
(value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS,
|
||||
`Value must be less than or equal to ${MAX_SAFE_TIMEOUT_MS}ms`,
|
||||
);
|
||||
const zIntervalMs = zStringTrimmed
|
||||
.refine((value) => ms((value ?? '0') as StringValue) >= 0, 'Value must be greater than or equal to 0')
|
||||
.refine(
|
||||
(value) => ms(value as StringValue) <= MAX_SAFE_TIMEOUT_MS,
|
||||
`Value must be less than or equal to ${MAX_SAFE_TIMEOUT_MS}ms`,
|
||||
);
|
||||
|
||||
const discordEmbed = z
|
||||
.union([
|
||||
@@ -227,6 +229,7 @@ export default typedPlugin(
|
||||
'Number of threads must be less than or equal to the number of CPUs: ' + cpus().length,
|
||||
),
|
||||
featuresThumbnailsFormat: z.enum(['jpg', 'png', 'webp']),
|
||||
featuresThumbnailsInstantaneous: z.boolean(),
|
||||
|
||||
featuresMetricsEnabled: z.boolean(),
|
||||
featuresMetricsAdminOnly: z.boolean(),
|
||||
@@ -471,7 +474,6 @@ export default typedPlugin(
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: 'No settings provided to update',
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fileSelect } from '@/lib/db/models/file';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { removeGps } from '@/lib/gps';
|
||||
import { log } from '@/lib/logger';
|
||||
import { runThumbnailWorkers } from '@/lib/tasks/run/thumbnails';
|
||||
import { parseHeaders, UploadHeaders } from '@/lib/uploader/parseHeaders';
|
||||
import { onUpload } from '@/lib/webhooks';
|
||||
import { Prisma } from '@/prisma/client';
|
||||
@@ -251,6 +252,19 @@ export default typedPlugin(
|
||||
.type('text/plain')
|
||||
.send(response.files.map((x) => x.url).join(','));
|
||||
|
||||
if (config.features.thumbnails.instantaneous) {
|
||||
logger.debug('running thumbnail workers immediately due to configuration', {
|
||||
files: response.files.length,
|
||||
});
|
||||
|
||||
const fileIds = response.files.map((x) => x.id);
|
||||
|
||||
const thumbnailWorkers = server.tasks.workersBy('thumbnail');
|
||||
if (!thumbnailWorkers.length) return;
|
||||
|
||||
runThumbnailWorkers(thumbnailWorkers, fileIds);
|
||||
}
|
||||
|
||||
return res.send(response);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiError } from '@/lib/api/errors';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSchema, fileSelect } from '@/lib/db/models/file';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zQsBoolean } from '@/lib/validation';
|
||||
import { paginationQs } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
@@ -29,27 +29,7 @@ export default typedPlugin(
|
||||
schema: {
|
||||
description:
|
||||
'List, filter, and search files for the authenticated user (or another user if permitted).',
|
||||
querystring: z.object({
|
||||
page: z.coerce.number(),
|
||||
perpage: z.coerce.number().default(15),
|
||||
filter: z.enum(['dashboard', 'none', 'all']).optional().default('none'),
|
||||
favorite: zQsBoolean.default(false).optional(),
|
||||
sortBy: z
|
||||
.enum([
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'deletesAt',
|
||||
'name',
|
||||
'originalName',
|
||||
'size',
|
||||
'type',
|
||||
'views',
|
||||
'favorite',
|
||||
])
|
||||
.optional()
|
||||
.default('createdAt'),
|
||||
order: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
querystring: paginationQs.extend({
|
||||
searchField: z.enum(['name', 'originalName', 'type', 'tags', 'id']).optional().default('name'),
|
||||
searchQuery: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { onShorten } from '@/lib/webhooks';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { z } from 'zod';
|
||||
import { reservedRoutes } from '../../server/settings';
|
||||
|
||||
export type ApiUserUrlsResponse =
|
||||
| Url[]
|
||||
@@ -33,7 +34,21 @@ export default typedPlugin(
|
||||
description:
|
||||
'Create a new shortened URL for the authenticated user, with optional vanity, password, and max-views settings.',
|
||||
body: z.object({
|
||||
vanity: zStringTrimmed.max(100).nullish(),
|
||||
vanity: zStringTrimmed
|
||||
.max(100)
|
||||
.refine((str) => !str.startsWith('/'), 'Vanity cannot start with a slash.')
|
||||
.refine(
|
||||
(str) =>
|
||||
!reservedRoutes.some((route) => {
|
||||
const nStr = `/${str}`.toLowerCase();
|
||||
const nRoute = route.toLowerCase();
|
||||
|
||||
return nStr === nRoute || nStr.startsWith(`${nRoute}/`);
|
||||
}),
|
||||
'Vanity cannot start with a reserved route.',
|
||||
)
|
||||
.optional()
|
||||
.nullish(),
|
||||
destination: z.httpUrl().min(1),
|
||||
enabled: z.boolean().optional(),
|
||||
}),
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
output: {
|
||||
format: isSSR ? 'cjs' : 'esm',
|
||||
entryFileNames: isSSR ? `${mode}.js` : '[name]-[hash].js',
|
||||
entryFileNames: isSSR ? `${mode}.js` : 'assets/[name]-[hash].js',
|
||||
},
|
||||
},
|
||||
target: 'esnext',
|
||||
|
||||
Reference in New Issue
Block a user