Compare commits

..

17 Commits

Author SHA1 Message Date
diced f0bcb4a019 feat(v4.5.3): version 2026-04-06 22:14:56 -07:00
diced 4c86b7fc38 fix: packages update + various perf fixes 2026-04-06 22:14:15 -07:00
diced 9b7759520c fix: random typos 2026-04-06 15:23:44 -07:00
diced e3e77c7916 feat: new server settings layout 2026-04-05 22:55:42 -07:00
diced 13282988e8 feat: instantaneous thumb generation 2026-04-05 19:06:02 -07:00
diced 00f4254227 fix: typo 2026-04-05 12:30:53 -07:00
diced 669c61eae0 fix: don't shorten reserved urls 2026-04-05 12:30:33 -07:00
diced 1ee1aca589 fix: hide other logins when none available 2026-04-02 22:24:12 -07:00
diced d49fd6a1f0 fix: build error 2026-04-01 17:55:29 -07:00
diced 8128e3deb0 fix: #1029 2026-04-01 17:53:22 -07:00
diced 67a9fe34b4 fix: use devalue for ssr 2026-03-31 23:30:10 -07:00
diced 7a3c4223ec fix: add warning 2026-03-31 17:16:54 -07:00
diced cb2590aae5 feat(v4.5.2): version 2026-03-28 23:58:39 -07:00
diced 93ff18a120 fix: reformat routes to include catch-all 2026-03-28 23:57:51 -07:00
diced 4343f130fb fix: refine batch uploads 2026-03-28 23:36:52 -07:00
diced 5e9778d18a fix: mfa showing when disabled 2026-03-28 20:35:49 -07:00
diced 9bcccbc8aa fix: #1031 2026-03-28 19:41:02 -07:00
58 changed files with 2235 additions and 2222 deletions
+3
View File
@@ -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
View File
@@ -101,7 +101,7 @@ export default defineConfig(
},
settings: {
react: { version: 'detect' },
react: { version: '19' },
},
},
);
+30 -31
View File
@@ -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"
+1232 -1530
View File
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;
+4 -3
View File
@@ -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)
+1 -1
View File
@@ -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.'
+38 -22
View File
@@ -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>
+51 -4
View File
@@ -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>
</>
);
+1 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+1 -1
View File
@@ -236,7 +236,7 @@ export default function DashboardFolders() {
{filesOpen ? '▼' : '▶'} {currentFolder.name}&#39;s files{' '}
{currentFolder._count ? `(${currentFolder._count.files})` : ''}
</Text>
<Collapse in={filesOpen}>
<Collapse expanded={filesOpen}>
{view === 'grid' ? (
<Paper withBorder p='sm'>
<FilesGridView folderId={currentFolderId} />
@@ -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>
@@ -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>
@@ -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>
+319 -92
View File
@@ -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 &quot;OAuth Registration&quot; 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 &quot;OAuth Registration&quot; 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 });
}
};
}
+1 -1
View File
@@ -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 ? (
+14 -11
View File
@@ -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: {
+38 -46
View File
@@ -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>
+5
View File
@@ -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 {}
+12 -10
View File
@@ -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' />,
+1 -1
View File
@@ -121,7 +121,7 @@ export function showUploadModal(
};
modals.open({
title: 'Uploaded files',
title: `Uploaded ${files.length} file${files.length > 1 ? 's' : ''}`,
size: 'auto',
children: (
<>
+1
View File
@@ -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',
+1
View File
@@ -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),
+1
View File
@@ -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),
+2
View File
@@ -63,6 +63,8 @@ export class S3Datasource extends Datasource {
keepAlive: true,
}),
}),
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
this.ensureReadWriteAccess();
+7 -3
View File
@@ -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);
}
}
+14 -1
View File
@@ -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>`;
}
+4
View File
@@ -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[];
}
}
+29 -22
View File
@@ -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),
);
};
}
+23
View File
@@ -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'),
});
+3 -1
View File
@@ -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',
});
+14
View File
@@ -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 -22
View File
@@ -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(),
+16 -1
View File
@@ -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
View File
@@ -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',