mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: database based server settings
This commit is contained in:
@@ -37,9 +37,9 @@
|
|||||||
"@mantine/hooks": "^7.2.2",
|
"@mantine/hooks": "^7.2.2",
|
||||||
"@mantine/modals": "^7.2.2",
|
"@mantine/modals": "^7.2.2",
|
||||||
"@mantine/notifications": "^7.2.2",
|
"@mantine/notifications": "^7.2.2",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.19.1",
|
||||||
"@prisma/internals": "^5.6.0",
|
"@prisma/internals": "^5.19.1",
|
||||||
"@prisma/migrate": "^5.6.0",
|
"@prisma/migrate": "^5.19.1",
|
||||||
"@tabler/icons-react": "^2.42.0",
|
"@tabler/icons-react": "^2.42.0",
|
||||||
"@xoi/gps-metadata-remover": "^1.1.2",
|
"@xoi/gps-metadata-remover": "^1.1.2",
|
||||||
"argon2": "^0.30.3",
|
"argon2": "^0.30.3",
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
"dot-prop": "^9.0.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"multer": "1.4.5-lts.1",
|
"multer": "1.4.5-lts.1",
|
||||||
"next": "^14.0.3",
|
"next": "^14.0.3",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"prisma": "^5.6.0",
|
"prisma": "^5.19.1",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
1792
pnpm-lock.yaml
generated
1792
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["omitApi"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -13,6 +14,107 @@ model Zipline {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
firstSetup Boolean @default(true)
|
firstSetup Boolean @default(true)
|
||||||
|
|
||||||
|
coreReturnHttpsUrls Boolean @default(false)
|
||||||
|
coreDefaultDomain String?
|
||||||
|
coreTempDirectory String // default join(tmpdir(), 'zipline')
|
||||||
|
|
||||||
|
chunksEnabled Boolean @default(true)
|
||||||
|
chunksMax Int @default(99614720)
|
||||||
|
chunksSize Int @default(26214400)
|
||||||
|
|
||||||
|
tasksDeleteInterval Int @default(1800000)
|
||||||
|
tasksClearInvitesInterval Int @default(1800000)
|
||||||
|
tasksMaxViewsInterval Int @default(1800000)
|
||||||
|
tasksThumbnailsInterval Int @default(1800000)
|
||||||
|
tasksMetricsInterval Int @default(1800000)
|
||||||
|
|
||||||
|
filesRoute String @default("/u")
|
||||||
|
filesLength Int @default(6)
|
||||||
|
filesDefaultFormat String @default("random")
|
||||||
|
filesDisabledExtensions String[]
|
||||||
|
filesMaxFileSize Int @default(104857600)
|
||||||
|
filesDefaultExpiration Int?
|
||||||
|
filesAssumeMimetypes Boolean @default(false)
|
||||||
|
filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss")
|
||||||
|
filesRemoveGpsMetadata Boolean @default(false)
|
||||||
|
|
||||||
|
urlsRoute String @default("/go")
|
||||||
|
urlsLength Int @default(6)
|
||||||
|
|
||||||
|
featuresImageCompression Boolean @default(true)
|
||||||
|
featuresRobotsTxt Boolean @default(true)
|
||||||
|
featuresHealthcheck Boolean @default(true)
|
||||||
|
featuresUserRegistration Boolean @default(false)
|
||||||
|
featuresOauthRegistration Boolean @default(false)
|
||||||
|
featuresDeleteOnMaxViews Boolean @default(true)
|
||||||
|
|
||||||
|
featuresThumbnailsEnabled Boolean @default(true)
|
||||||
|
featuresThumbnailsNumberThreads Int @default(4)
|
||||||
|
|
||||||
|
featuresMetricsEnabled Boolean @default(true)
|
||||||
|
featuresMetricsAdminOnly Boolean @default(false)
|
||||||
|
featuresMetricsShowUserSpecific Boolean @default(true)
|
||||||
|
|
||||||
|
invitesEnabled Boolean @default(true)
|
||||||
|
invitesLength Int @default(6)
|
||||||
|
|
||||||
|
websiteTitle String @default("Zipline")
|
||||||
|
websiteTitleLogo String?
|
||||||
|
websiteExternalLinks Json @default("[{ \"name\": \"GitHub\", \"url\": \"https://github.com/diced/zipline\"}, { \"name\": \"Documentation\", \"url\": \"https://zipline.diced.sh/\"}]")
|
||||||
|
websiteLoginBackground String?
|
||||||
|
websiteDefaultAvatar String?
|
||||||
|
|
||||||
|
websiteThemeDefault String @default("system")
|
||||||
|
websiteThemeDark String @default("builtin:dark_gray")
|
||||||
|
websiteThemeLight String @default("builtin:light_gray")
|
||||||
|
|
||||||
|
oauthBypassLocalLogin Boolean @default(false)
|
||||||
|
oauthLoginOnly Boolean @default(false)
|
||||||
|
|
||||||
|
oauthDiscordClientId String?
|
||||||
|
oauthDiscordClientSecret String?
|
||||||
|
|
||||||
|
oauthGoogleClientId String?
|
||||||
|
oauthGoogleClientSecret String?
|
||||||
|
|
||||||
|
oauthGithubClientId String?
|
||||||
|
oauthGithubClientSecret String?
|
||||||
|
|
||||||
|
oauthOidcClientId String?
|
||||||
|
oauthOidcClientSecret String?
|
||||||
|
oauthOidcAuthorizeUrl String?
|
||||||
|
oauthOidcTokenUrl String?
|
||||||
|
oauthOidcUserinfoUrl String?
|
||||||
|
|
||||||
|
mfaTotpEnabled Boolean @default(false)
|
||||||
|
mfaTotpIssuer String @default("Zipline")
|
||||||
|
mfaPasskeys Boolean @default(false)
|
||||||
|
|
||||||
|
ratelimitEnabled Boolean @default(true)
|
||||||
|
ratelimitMax Int @default(10)
|
||||||
|
ratelimitWindow Int?
|
||||||
|
ratelimitAdminBypass Boolean @default(true)
|
||||||
|
ratelimitAllowList String[]
|
||||||
|
|
||||||
|
httpWebhookOnUpload String?
|
||||||
|
httpWebhookOnShorten String?
|
||||||
|
|
||||||
|
discordWebhookUrl String?
|
||||||
|
discordUsername String?
|
||||||
|
discordAvatarUrl String?
|
||||||
|
|
||||||
|
discordOnUploadWebhookUrl String?
|
||||||
|
discordOnUploadUsername String?
|
||||||
|
discordOnUploadAvatarUrl String?
|
||||||
|
discordOnUploadContent String?
|
||||||
|
discordOnUploadEmbed Json?
|
||||||
|
|
||||||
|
discordOnShortenWebhookUrl String?
|
||||||
|
discordOnShortenUsername String?
|
||||||
|
discordOnShortenAvatarUrl String?
|
||||||
|
discordOnShortenContent String?
|
||||||
|
discordOnShortenEmbed Json?
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -113,7 +215,7 @@ model File {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletesAt DateTime?
|
deletesAt DateTime?
|
||||||
|
|
||||||
name String // name & file saved on datasource
|
name String // name & file saved on datasource
|
||||||
originalName String? // original name of file when uploaded
|
originalName String? // original name of file when uploaded
|
||||||
size BigInt
|
size BigInt
|
||||||
type String
|
type String
|
||||||
@@ -233,4 +335,4 @@ model Invite {
|
|||||||
|
|
||||||
inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
inviterId String
|
inviterId String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconRefreshDot,
|
IconRefreshDot,
|
||||||
|
IconSettingsExclamation,
|
||||||
IconSettingsFilled,
|
IconSettingsFilled,
|
||||||
IconShieldLockFilled,
|
IconShieldLockFilled,
|
||||||
IconTags,
|
IconTags,
|
||||||
@@ -114,6 +115,12 @@ const navLinks: NavLinks[] = [
|
|||||||
if: (user) => isAdministrator(user?.role),
|
if: (user) => isAdministrator(user?.role),
|
||||||
active: (path: string) => path.startsWith('/dashboard/admin'),
|
active: (path: string) => path.startsWith('/dashboard/admin'),
|
||||||
links: [
|
links: [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
icon: <IconSettingsFilled size='1rem' />,
|
||||||
|
active: (path: string) => path === '/dashboard/admin/settings',
|
||||||
|
href: '/dashboard/admin/settings',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
icon: <IconUsersGroup size='1rem' />,
|
icon: <IconUsersGroup size='1rem' />,
|
||||||
@@ -231,7 +238,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
|||||||
<Avatar src={config.website.titleLogo} alt='Zipline logo' radius='sm' size='md' mr='md' />
|
<Avatar src={config.website.titleLogo} alt='Zipline logo' radius='sm' size='md' mr='md' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Title fw={700}>Zipline</Title>
|
<Title fw={700}>{config.website.title.trim()}</Title>
|
||||||
|
|
||||||
<div style={{ marginLeft: 'auto' }}>
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
<Menu shadow='md' width={200}>
|
<Menu shadow='md' width={200}>
|
||||||
@@ -275,6 +282,16 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
|||||||
Settings
|
Settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{isAdministrator(user?.role) && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconSettingsExclamation size='1rem' />}
|
||||||
|
component={Link}
|
||||||
|
href='/dashboard/admin/settings'
|
||||||
|
>
|
||||||
|
Server Settings
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color='red'
|
color='red'
|
||||||
|
|||||||
64
src/components/pages/serverSettings/index.tsx
Normal file
64
src/components/pages/serverSettings/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Group, SimpleGrid, Stack, Title } from '@mantine/core';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import ServerSettingsChunks from './parts/ServerSettingsChunks';
|
||||||
|
import ServerSettingsCore from './parts/ServerSettingsCore';
|
||||||
|
import ServerSettingsDiscord from './parts/ServerSettingsDiscord';
|
||||||
|
import ServerSettingsFeatures from './parts/ServerSettingsFeatures';
|
||||||
|
import ServerSettingsFiles from './parts/ServerSettingsFiles';
|
||||||
|
import ServerSettingsHttpWebhook from './parts/ServerSettingsHttpWebhook';
|
||||||
|
import ServerSettingsInvites from './parts/ServerSettingsInvites';
|
||||||
|
import ServerSettingsMfa from './parts/ServerSettingsMfa';
|
||||||
|
import ServerSettingsOauth from './parts/ServerSettingsOauth';
|
||||||
|
import ServerSettingsRatelimit from './parts/ServerSettingsRatelimit';
|
||||||
|
import ServerSettingsTasks from './parts/ServerSettingsTasks';
|
||||||
|
import ServerSettingsUrls from './parts/ServerSettingsUrls';
|
||||||
|
import ServerSettingsWebsite from './parts/ServerSettingsWebsite';
|
||||||
|
|
||||||
|
export default function DashboardSettings() {
|
||||||
|
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group gap='sm'>
|
||||||
|
<Title order={1}>Server Settings</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
{error ? (
|
||||||
|
<div>Error loading server settings</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ServerSettingsCore swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsChunks swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsTasks swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsMfa swr={{ data, isLoading }} />
|
||||||
|
|
||||||
|
<ServerSettingsFeatures swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsFiles swr={{ data, isLoading }} />
|
||||||
|
<Stack>
|
||||||
|
<ServerSettingsUrls swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsInvites swr={{ data, isLoading }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ServerSettingsRatelimit swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsWebsite swr={{ data, isLoading }} />
|
||||||
|
<ServerSettingsOauth swr={{ data, isLoading }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Stack mt='md' gap='md'>
|
||||||
|
{error ? (
|
||||||
|
<div>Error loading server settings</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ServerSettingsHttpWebhook swr={{ data, isLoading }} />
|
||||||
|
|
||||||
|
<ServerSettingsDiscord swr={{ data, isLoading }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
import { bytes } from '@/lib/bytes';
|
||||||
|
|
||||||
|
export default function ServerSettingsChunks({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
chunksEnabled: true,
|
||||||
|
chunksMax: '95mb',
|
||||||
|
chunksSize: '25mb',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
chunksEnabled: data?.chunksEnabled ?? true,
|
||||||
|
chunksMax: bytes(data!.chunksMax),
|
||||||
|
chunksSize: bytes(data!.chunksSize),
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Chunks</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Enable Chunks'
|
||||||
|
description='Enable chunked uploads.'
|
||||||
|
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Max Chunk Size'
|
||||||
|
description='Maximum size of an upload before it is split into chunks.'
|
||||||
|
placeholder='95mb'
|
||||||
|
disabled={!form.values.chunksEnabled}
|
||||||
|
{...form.getInputProps('chunksMax')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Chunk Size'
|
||||||
|
description='Size of each chunk.'
|
||||||
|
placeholder='25mb'
|
||||||
|
disabled={!form.values.chunksEnabled}
|
||||||
|
{...form.getInputProps('chunksSize')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsCore({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<{
|
||||||
|
coreReturnHttpsUrls: boolean;
|
||||||
|
coreDefaultDomain: string | null | undefined;
|
||||||
|
coreTempDirectory: string;
|
||||||
|
}>({
|
||||||
|
initialValues: {
|
||||||
|
coreReturnHttpsUrls: false,
|
||||||
|
coreDefaultDomain: null,
|
||||||
|
coreTempDirectory: '/tmp/zipline',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
if (values.coreDefaultDomain?.trim() === '' || !values.coreDefaultDomain) {
|
||||||
|
values.coreDefaultDomain = null;
|
||||||
|
} else {
|
||||||
|
values.coreDefaultDomain = values.coreDefaultDomain.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValues({
|
||||||
|
coreReturnHttpsUrls: data?.coreReturnHttpsUrls ?? false,
|
||||||
|
coreDefaultDomain: data?.coreDefaultDomain ?? null,
|
||||||
|
coreTempDirectory: data?.coreTempDirectory ?? '/tmp/zipline',
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Core</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Return HTTPS URLs'
|
||||||
|
description='Return URLs with HTTPS protocol.'
|
||||||
|
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Default Domain'
|
||||||
|
description='The domain to use when generating URLs.'
|
||||||
|
placeholder='https://example.com'
|
||||||
|
{...form.getInputProps('coreDefaultDomain')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Temporary Directory'
|
||||||
|
description='The directory to store temporary files. If the path is invalid, certain functions may break. Requires a server restart.'
|
||||||
|
placeholder='/tmp/zipline'
|
||||||
|
{...form.getInputProps('coreTempDirectory')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Collapse, ColorInput, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
type DiscordEmbed = Record<string, any>;
|
||||||
|
|
||||||
|
export default function ServerSettingsDiscord({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formMain = useForm({
|
||||||
|
initialValues: {
|
||||||
|
discordWebhookUrl: '',
|
||||||
|
discordUsername: '',
|
||||||
|
discordAvatarUrl: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmitMain = async (values: typeof formMain.values) => {
|
||||||
|
for (const key in values) {
|
||||||
|
if ((values[key as keyof typeof formMain.values] as string)?.trim() === '') {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof formMain.values] = null;
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof formMain.values] = (
|
||||||
|
values[key as keyof typeof formMain.values] as string
|
||||||
|
)?.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, formMain)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formOnUpload = useForm({
|
||||||
|
initialValues: {
|
||||||
|
discordOnUploadWebhookUrl: '',
|
||||||
|
discordOnUploadUsername: '',
|
||||||
|
discordOnUploadAvatarUrl: '',
|
||||||
|
|
||||||
|
discordOnUploadContent: '',
|
||||||
|
|
||||||
|
discordOnUploadEmbed: false,
|
||||||
|
discordOnUploadEmbedTitle: '',
|
||||||
|
discordOnUploadEmbedDescription: '',
|
||||||
|
discordOnUploadEmbedFooter: '',
|
||||||
|
discordOnUploadEmbedColor: '',
|
||||||
|
discordOnUploadEmbedThumbnail: false,
|
||||||
|
discordOnUploadEmbedImageOrVideo: false,
|
||||||
|
discordOnUploadEmbedTimestamp: false,
|
||||||
|
discordOnUploadEmbedUrl: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formOnShorten = useForm({
|
||||||
|
initialValues: {
|
||||||
|
discordOnShortenWebhookUrl: '',
|
||||||
|
discordOnShortenUsername: '',
|
||||||
|
discordOnShortenAvatarUrl: '',
|
||||||
|
|
||||||
|
discordOnShortenContent: '',
|
||||||
|
|
||||||
|
discordOnShortenEmbed: false,
|
||||||
|
discordOnShortenEmbedTitle: '',
|
||||||
|
discordOnShortenEmbedDescription: '',
|
||||||
|
discordOnShortenEmbedFooter: '',
|
||||||
|
discordOnShortenEmbedColor: '',
|
||||||
|
discordOnShortenEmbedThumbnail: false,
|
||||||
|
discordOnShortenEmbedImageOrVideo: false,
|
||||||
|
discordOnShortenEmbedTimestamp: false,
|
||||||
|
discordOnShortenEmbedUrl: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmitNotif = (type: 'upload' | 'shorten') => async (values: Record<string, any>) => {
|
||||||
|
const sendValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
const prefix = type === 'upload' ? 'discordOnUpload' : 'discordOnShorten';
|
||||||
|
|
||||||
|
sendValues[`${prefix}WebhookUrl`] =
|
||||||
|
values[`${prefix}WebhookUrl`]?.trim() === '' ? null : values[`${prefix}WebhookUrl`]?.trim();
|
||||||
|
sendValues[`${prefix}Username`] =
|
||||||
|
values[`${prefix}Username`]?.trim() === '' ? null : values[`${prefix}Username`]?.trim();
|
||||||
|
sendValues[`${prefix}AvatarUrl`] =
|
||||||
|
values[`${prefix}AvatarUrl`]?.trim() === '' ? null : values[`${prefix}AvatarUrl`]?.trim();
|
||||||
|
sendValues[`${prefix}Content`] =
|
||||||
|
values[`${prefix}Content`]?.trim() === '' ? null : values[`${prefix}Content`]?.trim();
|
||||||
|
|
||||||
|
if (!values[`${prefix}Embed`]) {
|
||||||
|
sendValues[`${prefix}Embed`] = null;
|
||||||
|
} else {
|
||||||
|
sendValues[`${prefix}Embed`] = {
|
||||||
|
title: values[`${prefix}EmbedTitle`]?.trim() === '' ? null : values[`${prefix}EmbedTitle`]?.trim(),
|
||||||
|
description:
|
||||||
|
values[`${prefix}EmbedDescription`]?.trim() === ''
|
||||||
|
? null
|
||||||
|
: values[`${prefix}EmbedDescription`]?.trim(),
|
||||||
|
footer: values[`${prefix}EmbedFooter`]?.trim() === '' ? null : values[`${prefix}EmbedFooter`]?.trim(),
|
||||||
|
color: values[`${prefix}EmbedColor`]?.trim() === '' ? null : values[`${prefix}EmbedColor`]?.trim(),
|
||||||
|
thumbnail: values[`${prefix}EmbedThumbnail`],
|
||||||
|
imageOrVideo: values[`${prefix}EmbedImageOrVideo`],
|
||||||
|
timestamp: values[`${prefix}EmbedTimestamp`],
|
||||||
|
url: values[`${prefix}EmbedUrl`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
formMain.setValues({
|
||||||
|
discordWebhookUrl: data?.discordWebhookUrl ?? '',
|
||||||
|
discordUsername: data?.discordUsername ?? '',
|
||||||
|
discordAvatarUrl: data?.discordAvatarUrl ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
formOnUpload.setValues({
|
||||||
|
discordOnUploadWebhookUrl: data?.discordOnUploadWebhookUrl ?? '',
|
||||||
|
discordOnUploadUsername: data?.discordOnUploadUsername ?? '',
|
||||||
|
discordOnUploadAvatarUrl: data?.discordOnUploadAvatarUrl ?? '',
|
||||||
|
|
||||||
|
discordOnUploadContent: data?.discordOnUploadContent ?? '',
|
||||||
|
discordOnUploadEmbed: data?.discordOnUploadEmbed ? true : false,
|
||||||
|
discordOnUploadEmbedTitle: (data?.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||||
|
discordOnUploadEmbedDescription: (data?.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||||
|
discordOnUploadEmbedFooter: (data?.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||||
|
discordOnUploadEmbedColor: (data?.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||||
|
discordOnUploadEmbedThumbnail: (data?.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||||
|
discordOnUploadEmbedImageOrVideo: (data?.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||||
|
discordOnUploadEmbedTimestamp: (data?.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||||
|
discordOnUploadEmbedUrl: (data?.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
formOnShorten.setValues({
|
||||||
|
discordOnShortenWebhookUrl: data?.discordOnShortenWebhookUrl ?? '',
|
||||||
|
discordOnShortenUsername: data?.discordOnShortenUsername ?? '',
|
||||||
|
discordOnShortenAvatarUrl: data?.discordOnShortenAvatarUrl ?? '',
|
||||||
|
|
||||||
|
discordOnShortenContent: data?.discordOnShortenContent ?? '',
|
||||||
|
discordOnShortenEmbed: data?.discordOnShortenEmbed ? true : false,
|
||||||
|
discordOnShortenEmbedTitle: (data?.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||||
|
discordOnShortenEmbedDescription: (data?.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||||
|
discordOnShortenEmbedFooter: (data?.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||||
|
discordOnShortenEmbedColor: (data?.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||||
|
discordOnShortenEmbedThumbnail: (data?.discordOnShortenEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||||
|
discordOnShortenEmbedImageOrVideo: (data?.discordOnShortenEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||||
|
discordOnShortenEmbedTimestamp: (data?.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||||
|
discordOnShortenEmbedUrl: (data?.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<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/...'
|
||||||
|
{...formMain.getInputProps('discordWebhookUrl')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Username'
|
||||||
|
description='The username to send notifications as'
|
||||||
|
{...formMain.getInputProps('discordUsername')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Avatar URL'
|
||||||
|
description='The avatar for the webhook'
|
||||||
|
placeholder='https://example.com/avatar.png'
|
||||||
|
{...formMain.getInputProps('discordAvatarUrl')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={3}>On Upload</Title>
|
||||||
|
|
||||||
|
<form onSubmit={formOnUpload.onSubmit(onSubmitNotif('upload'))}>
|
||||||
|
<TextInput
|
||||||
|
mt='md'
|
||||||
|
label='Webhook URL'
|
||||||
|
description='The Discord webhook URL to send notifications to. If this is left blank, the main webhook url will be used'
|
||||||
|
placeholder='https://discord.com/api/webhooks/...'
|
||||||
|
{...formMain.getInputProps('discordOnUploadWebhookUrl')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Username'
|
||||||
|
description='The username to send notifications as. If this is left blank, the main username will be used'
|
||||||
|
{...formMain.getInputProps('discordOnUploadUsername')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Avatar URL'
|
||||||
|
description='The avatar for the webhook. If this is left blank, the main avatar will be used'
|
||||||
|
placeholder='https://example.com/avatar.png'
|
||||||
|
{...formMain.getInputProps('discordOnUploadAvatarUrl')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
mt='md'
|
||||||
|
label='Embed'
|
||||||
|
description='Send the notification as an embed. This will allow for more customization below.'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbed', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse in={formOnUpload.values.discordOnUploadEmbed}>
|
||||||
|
<Paper withBorder p='sm' mt='md'>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Title'
|
||||||
|
description='The title of the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedTitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Description'
|
||||||
|
description='The description of the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Footer'
|
||||||
|
description='The footer of the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedFooter')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorInput
|
||||||
|
label='Color'
|
||||||
|
description='The color of the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedColor')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Thumbnail'
|
||||||
|
description="Show the thumbnail (it will show the file if it's an image) in the embed"
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedThumbnail', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Image/Video'
|
||||||
|
description='Show the image or video in the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedImageOrVideo', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Timestamp'
|
||||||
|
description='Show the timestamp in the embed'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedTimestamp', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='URL'
|
||||||
|
description='Makes the title clickable and links to the URL of the file'
|
||||||
|
{...formOnUpload.getInputProps('discordOnUploadEmbedUrl', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={3}>On Shorten</Title>
|
||||||
|
|
||||||
|
<form onSubmit={formOnShorten.onSubmit(onSubmitNotif('shorten'))}>
|
||||||
|
<TextInput
|
||||||
|
mt='md'
|
||||||
|
label='Webhook URL'
|
||||||
|
description='The Discord webhook URL to send notifications to. If this is left blank, the main webhook url will be used'
|
||||||
|
placeholder='https://discord.com/api/webhooks/...'
|
||||||
|
{...formMain.getInputProps('discordOnShortenWebhookUrl')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Username'
|
||||||
|
description='The username to send notifications as. If this is left blank, the main username will be used'
|
||||||
|
{...formMain.getInputProps('discordOnShortenUsername')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Avatar URL'
|
||||||
|
description='The avatar for the webhook. If this is left blank, the main avatar will be used'
|
||||||
|
placeholder='https://example.com/avatar.png'
|
||||||
|
{...formMain.getInputProps('discordOnShortenAvatarUrl')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
mt='md'
|
||||||
|
label='Embed'
|
||||||
|
description='Send the notification as an embed. This will allow for more customization below.'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbed', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse in={formOnShorten.values.discordOnShortenEmbed}>
|
||||||
|
<Paper withBorder p='sm' mt='md'>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Title'
|
||||||
|
description='The title of the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedTitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Description'
|
||||||
|
description='The description of the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Footer'
|
||||||
|
description='The footer of the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedFooter')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorInput
|
||||||
|
label='Color'
|
||||||
|
description='The color of the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedColor')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Thumbnail'
|
||||||
|
description="Show the thumbnail (it will show the file if it's an image) in the embed"
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedThumbnail', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Image/Video'
|
||||||
|
description='Show the image or video in the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedImageOrVideo', {
|
||||||
|
type: 'checkbox',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Timestamp'
|
||||||
|
description='Show the timestamp in the embed'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedTimestamp', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='URL'
|
||||||
|
description='Makes the title clickable and links to the URL of the file'
|
||||||
|
{...formOnShorten.getInputProps('discordOnShortenEmbedUrl', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconCpu, IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsFeatures({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
featuresImageCompression: true,
|
||||||
|
featuresRobotsTxt: true,
|
||||||
|
featuresHealthcheck: true,
|
||||||
|
featuresUserRegistration: false,
|
||||||
|
featuresOauthRegistration: true,
|
||||||
|
featuresDeleteOnMaxViews: true,
|
||||||
|
featuresThumbnailsEnabled: true,
|
||||||
|
featuresThumbnailsNumberThreads: 4,
|
||||||
|
featuresMetricsEnabled: true,
|
||||||
|
featuresMetricsAdminOnly: false,
|
||||||
|
featuresMetricsShowUserSpecific: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValues({
|
||||||
|
featuresImageCompression: data?.featuresImageCompression ?? true,
|
||||||
|
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
|
||||||
|
featuresHealthcheck: data?.featuresHealthcheck ?? true,
|
||||||
|
featuresUserRegistration: data?.featuresUserRegistration ?? false,
|
||||||
|
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
|
||||||
|
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
|
||||||
|
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
|
||||||
|
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
|
||||||
|
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
|
||||||
|
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
|
||||||
|
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Features</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Image Compression'
|
||||||
|
description='Automatically compresses images uploaded to the server.'
|
||||||
|
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Robots.txt'
|
||||||
|
description='Enables a robots.txt file for search engine optimization. Requires a server restart.'
|
||||||
|
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Healthcheck'
|
||||||
|
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
|
||||||
|
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='User Registration'
|
||||||
|
description='Allows users to register an account on the server.'
|
||||||
|
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='OAuth Registration'
|
||||||
|
description='Allows users to register an account using OAuth providers.'
|
||||||
|
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Delete on Max Views'
|
||||||
|
description='Automatically deletes files after they reach the maximum view count.'
|
||||||
|
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Metrics Enabled'
|
||||||
|
description='Enables metrics for the server. Requires a server restart.'
|
||||||
|
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Metrics Admin Only'
|
||||||
|
description='Requires an administrator to view metrics.'
|
||||||
|
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Metrics Show User Specific'
|
||||||
|
description='Shows metrics specific to each user, for all users.'
|
||||||
|
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Thumbnails Enabled'
|
||||||
|
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||||
|
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Thumbnails Number Threads'
|
||||||
|
description='Number of threads to use for thumbnail generation, usually the number of CPU threads. Requires a server restart.'
|
||||||
|
placeholder='Enter a number...'
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
leftSection={<IconCpu size='1rem' />}
|
||||||
|
{...form.getInputProps('featuresThumbnailsNumberThreads')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, NumberInput, Paper, Select, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
import { bytes } from '@/lib/bytes';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
|
export default function ServerSettingsFiles({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<{
|
||||||
|
filesRoute: string;
|
||||||
|
filesLength: number;
|
||||||
|
filesDefaultFormat: string;
|
||||||
|
filesDisabledExtensions: string;
|
||||||
|
filesMaxFileSize: string;
|
||||||
|
filesDefaultExpiration: string | null;
|
||||||
|
filesAssumeMimetypes: boolean;
|
||||||
|
filesDefaultDateFormat: string;
|
||||||
|
filesRemoveGpsMetadata: boolean;
|
||||||
|
}>({
|
||||||
|
initialValues: {
|
||||||
|
filesRoute: '/u',
|
||||||
|
filesLength: 6,
|
||||||
|
filesDefaultFormat: 'random',
|
||||||
|
filesDisabledExtensions: '',
|
||||||
|
filesMaxFileSize: '100mb',
|
||||||
|
filesDefaultExpiration: null,
|
||||||
|
filesAssumeMimetypes: false,
|
||||||
|
filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss',
|
||||||
|
filesRemoveGpsMetadata: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
if (values.filesDefaultExpiration?.trim() === '' || !values.filesDefaultExpiration) {
|
||||||
|
values.filesDefaultExpiration = null;
|
||||||
|
} else {
|
||||||
|
values.filesDefaultExpiration = values.filesDefaultExpiration.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.filesDisabledExtensions?.trim() === '' || !values.filesDisabledExtensions) {
|
||||||
|
// @ts-ignore
|
||||||
|
values.filesDisabledExtensions = [];
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
values.filesDisabledExtensions = values.filesDisabledExtensions
|
||||||
|
.split(',')
|
||||||
|
.map((ext) => ext.trim())
|
||||||
|
.filter((ext) => ext !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setValues({
|
||||||
|
filesRoute: data?.filesRoute ?? '/u',
|
||||||
|
filesLength: data?.filesLength ?? 6,
|
||||||
|
filesDefaultFormat: data?.filesDefaultFormat ?? 'random',
|
||||||
|
filesDisabledExtensions: data?.filesDisabledExtensions.join(', ') ?? '',
|
||||||
|
filesMaxFileSize: bytes(data?.filesMaxFileSize ?? 104857600),
|
||||||
|
filesDefaultExpiration: data?.filesDefaultExpiration ? ms(data.filesDefaultExpiration) : null,
|
||||||
|
filesAssumeMimetypes: data?.filesAssumeMimetypes ?? false,
|
||||||
|
filesDefaultDateFormat: data?.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||||
|
filesRemoveGpsMetadata: data?.filesRemoveGpsMetadata ?? false,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Files</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Route'
|
||||||
|
description='The route to use for file uploads. Requires a server restart.'
|
||||||
|
placeholder='/u'
|
||||||
|
{...form.getInputProps('filesRoute')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Length'
|
||||||
|
description='The length of the file name (for randomly generated names).'
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
{...form.getInputProps('filesLength')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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.'
|
||||||
|
placeholder='random'
|
||||||
|
data={['random', 'date', 'uuid', 'name', 'gfycat']}
|
||||||
|
{...form.getInputProps('filesDefaultFormat')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Disabled Extensions'
|
||||||
|
description='Extensions to disable, separated by commas.'
|
||||||
|
placeholder='exe, bat, sh'
|
||||||
|
{...form.getInputProps('filesDisabledExtensions')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Max File Size'
|
||||||
|
description='The maximum file size allowed.'
|
||||||
|
placeholder='100mb'
|
||||||
|
{...form.getInputProps('filesMaxFileSize')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Default Expiration'
|
||||||
|
description='The default expiration time for files.'
|
||||||
|
placeholder='30d'
|
||||||
|
{...form.getInputProps('filesDefaultExpiration')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Default Date Format'
|
||||||
|
description='The default date format to use.'
|
||||||
|
placeholder='YYYY-MM-DD_HH:mm:ss'
|
||||||
|
{...form.getInputProps('filesDefaultDateFormat')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsHttpWebhook({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
httpWebhookOnUpload: '',
|
||||||
|
httpWebhookOnShorten: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
for (const key in values) {
|
||||||
|
if ((values[key as keyof typeof form.values] as string)?.trim() === '') {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof form.values] = null;
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof form.values] = (values[key as keyof typeof form.values] as string)?.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
httpWebhookOnUpload: data?.httpWebhookOnUpload ?? '',
|
||||||
|
httpWebhookOnShorten: data?.httpWebhookOnShorten ?? '',
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>HTTP Webhooks</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='On Upload'
|
||||||
|
description='The URL to send a POST request to when a file is uploaded.'
|
||||||
|
placeholder='https://example.com/upload'
|
||||||
|
{...form.getInputProps('httpWebhookOnUpload')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='On Shorten'
|
||||||
|
description='The URL to send a POST request to when a URL is shortened.'
|
||||||
|
placeholder='https://example.com/shorten'
|
||||||
|
{...form.getInputProps('httpWebhookOnShorten')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsInvites({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
invitesEnabled: true,
|
||||||
|
invitesLength: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
invitesEnabled: data?.invitesEnabled ?? true,
|
||||||
|
invitesLength: data?.invitesLength ?? 6,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm' h='100%'>
|
||||||
|
<Title order={2}>Invites</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Enable Chunks'
|
||||||
|
description='Enable chunked uploads.'
|
||||||
|
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Length'
|
||||||
|
description='The length of the invite code.'
|
||||||
|
placeholder='6'
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
{...form.getInputProps('invitesLength')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsMfa({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
mfaTotpEnabled: false,
|
||||||
|
mfaTotpIssuer: 'Zipline',
|
||||||
|
mfaPasskeys: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
mfaTotpEnabled: data?.mfaTotpEnabled ?? false,
|
||||||
|
mfaTotpIssuer: data?.mfaTotpIssuer ?? 'Zipline',
|
||||||
|
mfaPasskeys: data?.mfaPasskeys,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Multi-Factor Authentication</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Passkeys'
|
||||||
|
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security, etc.'
|
||||||
|
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Enable TOTP'
|
||||||
|
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
|
||||||
|
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label='Issuer'
|
||||||
|
description='The issuer to use for the TOTP token.'
|
||||||
|
placeholder='Zipline'
|
||||||
|
{...form.getInputProps('mfaTotpIssuer')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, Switch, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsOauth({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
oauthBypassLocalLogin: false,
|
||||||
|
oauthLoginOnly: false,
|
||||||
|
|
||||||
|
oauthDiscordClientId: '',
|
||||||
|
oauthDiscordClientSecret: '',
|
||||||
|
|
||||||
|
oauthGoogleClientId: '',
|
||||||
|
oauthGoogleClientSecret: '',
|
||||||
|
|
||||||
|
oauthGithubClientId: '',
|
||||||
|
oauthGithubClientSecret: '',
|
||||||
|
|
||||||
|
oauthOidcClientId: '',
|
||||||
|
oauthOidcClientSecret: '',
|
||||||
|
oauthOidcAuthorizeUrl: '',
|
||||||
|
oauthOidcTokenUrl: '',
|
||||||
|
oauthOidcUserinfoUrl: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
for (const key in values) {
|
||||||
|
if (!['oauthBypassLocalLogin', 'oauthLoginOnly'].includes(key)) {
|
||||||
|
if ((values[key as keyof typeof form.values] as string)?.trim() === '') {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof form.values] = null;
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
values[key as keyof typeof form.values] = (
|
||||||
|
values[key as keyof typeof form.values] as string
|
||||||
|
)?.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
oauthBypassLocalLogin: data?.oauthBypassLocalLogin ?? false,
|
||||||
|
oauthLoginOnly: data?.oauthLoginOnly ?? false,
|
||||||
|
|
||||||
|
oauthDiscordClientId: data?.oauthDiscordClientId ?? '',
|
||||||
|
oauthDiscordClientSecret: data?.oauthDiscordClientSecret ?? '',
|
||||||
|
|
||||||
|
oauthGoogleClientId: data?.oauthGoogleClientId ?? '',
|
||||||
|
oauthGoogleClientSecret: data?.oauthGoogleClientSecret ?? '',
|
||||||
|
|
||||||
|
oauthGithubClientId: data?.oauthGithubClientId ?? '',
|
||||||
|
oauthGithubClientSecret: data?.oauthGithubClientSecret ?? '',
|
||||||
|
|
||||||
|
oauthOidcClientId: data?.oauthOidcClientId ?? '',
|
||||||
|
oauthOidcClientSecret: data?.oauthOidcClientSecret ?? '',
|
||||||
|
oauthOidcAuthorizeUrl: data?.oauthOidcAuthorizeUrl ?? '',
|
||||||
|
oauthOidcTokenUrl: data?.oauthOidcTokenUrl ?? '',
|
||||||
|
oauthOidcUserinfoUrl: data?.oauthOidcUserinfoUrl ?? '',
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>OAuth</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='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.'
|
||||||
|
{...form.getInputProps('oauthBypassLocalLogin', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Login Only'
|
||||||
|
description='Disables registration and only allows login with OAuth, existing users can link providers for example.'
|
||||||
|
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Paper withBorder p='sm' my='md'>
|
||||||
|
<Text size='md' fw={700}>
|
||||||
|
Discord
|
||||||
|
</Text>
|
||||||
|
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||||
|
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p='sm' my='md'>
|
||||||
|
<Text size='md' fw={700}>
|
||||||
|
Google
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput label='Google Client ID' {...form.getInputProps('oauthGoogleClientId')} />
|
||||||
|
<TextInput label='Google Client Secret' {...form.getInputProps('oauthGoogleClientSecret')} />
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Paper withBorder p='sm' my='md'>
|
||||||
|
<Text size='md' fw={700}>
|
||||||
|
GitHub
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} />
|
||||||
|
<TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p='sm' mt='md'>
|
||||||
|
<Text size='md' fw={700}>
|
||||||
|
OpenID Connect
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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')} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, NumberInput, Paper, SimpleGrid, Switch, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsRatelimit({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<{
|
||||||
|
ratelimitEnabled: boolean;
|
||||||
|
ratelimitMax: number;
|
||||||
|
ratelimitWindow: number | '';
|
||||||
|
ratelimitAdminBypass: boolean;
|
||||||
|
ratelimitAllowList: string;
|
||||||
|
}>({
|
||||||
|
initialValues: {
|
||||||
|
ratelimitEnabled: true,
|
||||||
|
ratelimitMax: 10,
|
||||||
|
ratelimitWindow: '',
|
||||||
|
ratelimitAdminBypass: false,
|
||||||
|
ratelimitAllowList: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
if (values.ratelimitAllowList?.trim() === '' || !values.ratelimitAllowList) {
|
||||||
|
// @ts-ignore
|
||||||
|
values.ratelimitAllowList = [];
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
values.ratelimitAllowList = values.ratelimitAllowList
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter((x) => x !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.ratelimitWindow === '') {
|
||||||
|
// @ts-ignore
|
||||||
|
values.ratelimitWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
ratelimitEnabled: data?.ratelimitEnabled ?? true,
|
||||||
|
ratelimitMax: data?.ratelimitMax ?? 10,
|
||||||
|
ratelimitWindow: data?.ratelimitWindow ?? '',
|
||||||
|
ratelimitAdminBypass: data?.ratelimitAdminBypass ?? false,
|
||||||
|
ratelimitAllowList: data?.ratelimitAllowList.join(', ') ?? '',
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Ratelimit</Title>
|
||||||
|
|
||||||
|
<Text c='dimmed' size='sm'>
|
||||||
|
All options require a restart to take effect.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<Switch
|
||||||
|
label='Enable Ratelimit'
|
||||||
|
description='Enable ratelimiting for the server.'
|
||||||
|
{...form.getInputProps('ratelimitEnabled', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Admin Bypass'
|
||||||
|
description='Allow admins to bypass the ratelimit.'
|
||||||
|
disabled={!form.values.ratelimitEnabled}
|
||||||
|
{...form.getInputProps('ratelimitAdminBypass', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Max Requests'
|
||||||
|
description='The maximum number of requests allowed within the window. If no window is set, this is the maximum number of requests until it reaches the limit.'
|
||||||
|
placeholder='10'
|
||||||
|
min={1}
|
||||||
|
disabled={!form.values.ratelimitEnabled}
|
||||||
|
{...form.getInputProps('ratelimitMax')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Window'
|
||||||
|
description='The window in seconds to allow the max requests.'
|
||||||
|
placeholder='60'
|
||||||
|
min={1}
|
||||||
|
disabled={!form.values.ratelimitEnabled}
|
||||||
|
{...form.getInputProps('ratelimitWindow')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Allow List'
|
||||||
|
description='A comma-separated list of IP addresses to bypass the ratelimit.'
|
||||||
|
placeholder='1.1.1.1, 8.8.8.8'
|
||||||
|
disabled={!form.values.ratelimitEnabled}
|
||||||
|
{...form.getInputProps('ratelimitAllowList')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsTasks({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
tasksDeleteInterval: ms(1800000),
|
||||||
|
tasksClearInvitesInterval: ms(1800000),
|
||||||
|
tasksMaxViewsInterval: ms(1800000),
|
||||||
|
tasksThumbnailsInterval: ms(1800000),
|
||||||
|
tasksMetricsInterval: ms(1800000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
tasksDeleteInterval: ms(data?.tasksDeleteInterval ?? 1800000),
|
||||||
|
tasksClearInvitesInterval: ms(data?.tasksClearInvitesInterval ?? 1800000),
|
||||||
|
tasksMaxViewsInterval: ms(data?.tasksMaxViewsInterval ?? 1800000),
|
||||||
|
tasksThumbnailsInterval: ms(data?.tasksThumbnailsInterval ?? 1800000),
|
||||||
|
tasksMetricsInterval: ms(data?.tasksMetricsInterval ?? 1800000),
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Tasks</Title>
|
||||||
|
|
||||||
|
<Text c='dimmed' size='sm'>
|
||||||
|
All options require a restart to take effect.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Delete Files Interval'
|
||||||
|
description='How often to check and delete expired files.'
|
||||||
|
placeholder='30m'
|
||||||
|
{...form.getInputProps('tasksDeleteInterval')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Clear Invites Interval'
|
||||||
|
description='How often to check and clear expired/used invites.'
|
||||||
|
placeholder='30m'
|
||||||
|
{...form.getInputProps('tasksClearInvitesInterval')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Max Views Interval'
|
||||||
|
description='How often to check and delete files that have reached max views.'
|
||||||
|
placeholder='30m'
|
||||||
|
{...form.getInputProps('tasksMaxViewsInterval')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Thumbnails Interval'
|
||||||
|
description='How often to check and generate thumbnails for video files.'
|
||||||
|
placeholder='30m'
|
||||||
|
{...form.getInputProps('tasksThumbnailsInterval')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, NumberInput, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function ServerSettingsUrls({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
urlsRoute: '/go',
|
||||||
|
urlsLength: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
urlsRoute: data?.urlsRoute ?? '/go',
|
||||||
|
urlsLength: data?.urlsLength ?? 6,
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>URL Shortener</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Route'
|
||||||
|
description='The route to use for short URLs.'
|
||||||
|
placeholder='/go'
|
||||||
|
{...form.getInputProps('urlsRoute')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label='Length'
|
||||||
|
description='The length of the short URL (for randomly generated names).'
|
||||||
|
placeholder='6'
|
||||||
|
min={1}
|
||||||
|
max={64}
|
||||||
|
{...form.getInputProps('urlsLength')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
const defaultExternalLinks = [
|
||||||
|
{
|
||||||
|
name: 'GitHub',
|
||||||
|
url: 'https://github.com/diced/zipline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Documentation',
|
||||||
|
url: 'https://zipline.diced.tech',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ServerSettingsWebsite({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
websiteTitle: 'Zipline',
|
||||||
|
websiteTitleLogo: '',
|
||||||
|
websiteExternalLinks: JSON.stringify(defaultExternalLinks),
|
||||||
|
websiteLoginBackground: '',
|
||||||
|
websiteDefaultAvatar: '',
|
||||||
|
|
||||||
|
websiteThemeDefault: 'system',
|
||||||
|
websiteThemeDark: 'builtin:dark_gray',
|
||||||
|
websiteThemeLight: 'builtin:light_gray',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
|
const sendValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (values.websiteExternalLinks?.trim() === '' || !values.websiteExternalLinks) {
|
||||||
|
// @ts-ignore
|
||||||
|
sendValues.websiteExternalLinks = [];
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
try {
|
||||||
|
sendValues.websiteExternalLinks = JSON.parse(values.websiteExternalLinks);
|
||||||
|
} catch (e) {
|
||||||
|
form.setFieldError('websiteExternalLinks', 'Invalid JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendValues.websiteTitleLogo =
|
||||||
|
values.websiteTitleLogo.trim() === '' ? null : values.websiteTitleLogo.trim();
|
||||||
|
sendValues.websiteLoginBackground =
|
||||||
|
values.websiteLoginBackground.trim() === '' ? null : values.websiteLoginBackground.trim();
|
||||||
|
sendValues.websiteDefaultAvatar =
|
||||||
|
values.websiteDefaultAvatar.trim() === '' ? null : values.websiteDefaultAvatar.trim();
|
||||||
|
|
||||||
|
sendValues.websiteThemeDefault = values.websiteThemeDefault.trim();
|
||||||
|
sendValues.websiteThemeDark = values.websiteThemeDark.trim();
|
||||||
|
sendValues.websiteThemeLight = values.websiteThemeLight.trim();
|
||||||
|
sendValues.websiteTitle = values.websiteTitle.trim();
|
||||||
|
|
||||||
|
return settingsOnSubmit(router, form)(sendValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
websiteTitle: data?.websiteTitle ?? 'Zipline',
|
||||||
|
websiteTitleLogo: data?.websiteTitleLogo ?? '',
|
||||||
|
websiteExternalLinks: JSON.stringify(data?.websiteExternalLinks ?? defaultExternalLinks),
|
||||||
|
websiteLoginBackground: data?.websiteLoginBackground ?? '',
|
||||||
|
websiteDefaultAvatar: data?.websiteDefaultAvatar ?? '',
|
||||||
|
websiteThemeDefault: data?.websiteThemeDefault ?? 'system',
|
||||||
|
websiteThemeDark: data?.websiteThemeDark ?? 'builtin:dark_gray',
|
||||||
|
websiteThemeLight: data?.websiteThemeLight ?? 'builtin:light_gray',
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Title order={2}>Website</Title>
|
||||||
|
|
||||||
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
|
<TextInput
|
||||||
|
label='Title'
|
||||||
|
description='The title of the website in browser tabs and at the top.'
|
||||||
|
placeholder='Zipline'
|
||||||
|
{...form.getInputProps('websiteTitle')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Title Logo'
|
||||||
|
description='The URL to use for the title logo. This is placed to the left of the title.'
|
||||||
|
placeholder='https://example.com/logo.png'
|
||||||
|
{...form.getInputProps('websiteTitleLogo')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='External Links'
|
||||||
|
description='The external links to show in the footer. This must be valid JSON.'
|
||||||
|
placeholder={JSON.stringify(defaultExternalLinks)}
|
||||||
|
{...form.getInputProps('websiteExternalLinks')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Login Background'
|
||||||
|
description='The URL to use for the login background.'
|
||||||
|
placeholder='https://example.com/background.png'
|
||||||
|
{...form.getInputProps('websiteLoginBackground')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Default Avatar'
|
||||||
|
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
|
||||||
|
placeholder='/zipline/avatar.png'
|
||||||
|
{...form.getInputProps('websiteDefaultAvatar')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Default Theme'
|
||||||
|
description='The default theme to use for the website.'
|
||||||
|
placeholder='system'
|
||||||
|
{...form.getInputProps('websiteThemeDefault')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label='Dark Theme'
|
||||||
|
description='The dark theme to use for the website when the default theme is "system".'
|
||||||
|
placeholder='builtin:dark_gray'
|
||||||
|
disabled={form.values.websiteThemeDefault !== 'system'}
|
||||||
|
{...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'
|
||||||
|
disabled={form.values.websiteThemeDefault !== 'system'}
|
||||||
|
{...form.getInputProps('websiteThemeLight')}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
color='blue'
|
||||||
|
mt='md'
|
||||||
|
loading={isLoading}
|
||||||
|
leftSection={<IconDeviceFloppy size='1rem' />}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/pages/serverSettings/settingsOnSubmit.tsx
Normal file
47
src/components/pages/serverSettings/settingsOnSubmit.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { fetchApi } from '@/lib/fetchApi';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import { NextRouter } from 'next/router';
|
||||||
|
import { mutate } from 'swr';
|
||||||
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
|
||||||
|
export function settingsOnSubmit(router: NextRouter, form: ReturnType<typeof useForm<any>>) {
|
||||||
|
return async (values: unknown) => {
|
||||||
|
const { data, error } = await fetchApi<Response['/api/server/settings']>(
|
||||||
|
'/api/server/settings',
|
||||||
|
'PATCH',
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
showNotification({
|
||||||
|
title: 'Failed to save settings',
|
||||||
|
message: error.issues
|
||||||
|
? error.issues.map((x: { message: string }) => x.message).join('\n')
|
||||||
|
: error.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.issues) {
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
for (let i = 0; i < issue.path.length; i++) {
|
||||||
|
form.setFieldError(issue.path[i], issue.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification({
|
||||||
|
message: 'Settings saved',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconDeviceFloppy size='1rem' />,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetch('/reload');
|
||||||
|
mutate('/api/server/settings', data);
|
||||||
|
router.replace(router.asPath, undefined, { scroll: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ApiHealthcheckResponse } from '@/server/routes/api/healthcheck';
|
|||||||
import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_temp';
|
import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_temp';
|
||||||
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
|
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
|
||||||
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
|
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
|
||||||
|
import { ApiServerSettingsResponse } from '@/server/routes/api/server/settings';
|
||||||
import { ApiSetupResponse } from '@/server/routes/api/setup';
|
import { ApiSetupResponse } from '@/server/routes/api/setup';
|
||||||
import { ApiStatsResponse } from '@/server/routes/api/stats';
|
import { ApiStatsResponse } from '@/server/routes/api/stats';
|
||||||
import { ApiUploadResponse } from '@/server/routes/api/upload';
|
import { ApiUploadResponse } from '@/server/routes/api/upload';
|
||||||
@@ -69,6 +70,7 @@ export type Response = {
|
|||||||
'/api/server/clear_temp': ApiServerClearTempResponse;
|
'/api/server/clear_temp': ApiServerClearTempResponse;
|
||||||
'/api/server/clear_zeros': ApiServerClearZerosResponse;
|
'/api/server/clear_zeros': ApiServerClearZerosResponse;
|
||||||
'/api/server/requery_size': ApiServerRequerySizeResponse;
|
'/api/server/requery_size': ApiServerRequerySizeResponse;
|
||||||
|
'/api/server/settings': ApiServerSettingsResponse;
|
||||||
'/api/healthcheck': ApiHealthcheckResponse;
|
'/api/healthcheck': ApiHealthcheckResponse;
|
||||||
'/api/setup': ApiSetupResponse;
|
'/api/setup': ApiSetupResponse;
|
||||||
'/api/upload': ApiUploadResponse;
|
'/api/upload': ApiUploadResponse;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readEnv } from './read';
|
import { read } from './read';
|
||||||
import { validateEnv, Config } from './validate';
|
import { validateConfigObject, Config } from './validate';
|
||||||
|
|
||||||
let config: Config;
|
let config: Config;
|
||||||
|
|
||||||
@@ -8,11 +8,10 @@ declare global {
|
|||||||
var __config__: Config;
|
var __config__: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.__config__) {
|
const reloadSettings = async () => {
|
||||||
global.__config__ = validateEnv(readEnv());
|
config = global.__config__ = validateConfigObject((await read()) as any);
|
||||||
}
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-const
|
|
||||||
config = global.__config__;
|
config = global.__config__;
|
||||||
|
|
||||||
export { config };
|
export { config, reloadSettings };
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import msFn from 'ms';
|
import msFn from 'ms';
|
||||||
import { log } from '../logger';
|
import { log } from '../logger';
|
||||||
import { bytes } from '../bytes';
|
import { bytes } from '../bytes';
|
||||||
|
import { prisma } from '../db';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { setProperty } from 'dot-prop';
|
||||||
|
|
||||||
type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json[]';
|
type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json[]';
|
||||||
|
|
||||||
export type ParsedEnv = ReturnType<typeof readEnv>;
|
export type ParsedConfig = ReturnType<typeof read>;
|
||||||
|
|
||||||
export const rawConfig: any = {
|
export const rawConfig: any = {
|
||||||
core: {
|
core: {
|
||||||
@@ -133,32 +137,6 @@ export const PROP_TO_ENV = {
|
|||||||
'core.hostname': 'CORE_HOSTNAME',
|
'core.hostname': 'CORE_HOSTNAME',
|
||||||
'core.secret': 'CORE_SECRET',
|
'core.secret': 'CORE_SECRET',
|
||||||
'core.databaseUrl': ['CORE_DATABASE_URL', 'DATABASE_URL'],
|
'core.databaseUrl': ['CORE_DATABASE_URL', 'DATABASE_URL'],
|
||||||
'core.returnHttpsUrls': 'CORE_RETURN_HTTPS_URLS',
|
|
||||||
'core.defaultDomain': 'CORE_DEFAULT_DOMAIN',
|
|
||||||
'core.tempDirectory': 'CORE_TEMP_DIRECTORY',
|
|
||||||
|
|
||||||
'chunks.max': 'CHUNKS_MAX',
|
|
||||||
'chunks.size': 'CHUNKS_SIZE',
|
|
||||||
'chunks.enabled': 'CHUNKS_ENABLED',
|
|
||||||
|
|
||||||
'tasks.deleteInterval': 'TASKS_DELETE_INTERVAL',
|
|
||||||
'tasks.clearInvitesInterval': 'TASKS_CLEAR_INVITES_INTERVAL',
|
|
||||||
'tasks.maxViewsInterval': 'TASKS_MAX_VIEWS_INTERVAL',
|
|
||||||
'tasks.thumbnailsInterval': 'TASKS_THUMBNAILS_INTERVAL',
|
|
||||||
'tasks.metricsInterval': 'TASKS_METRICS_INTERVAL',
|
|
||||||
|
|
||||||
'files.route': 'FILES_ROUTE',
|
|
||||||
'files.length': 'FILES_LENGTH',
|
|
||||||
'files.defaultFormat': 'FILES_DEFAULT_FORMAT',
|
|
||||||
'files.disabledExtensions': 'FILES_DISABLED_EXTENSIONS',
|
|
||||||
'files.maxFileSize': 'FILES_MAX_FILE_SIZE',
|
|
||||||
'files.defaultExpiration': 'FILES_DEFAULT_EXPIRATION',
|
|
||||||
'files.assumeMimetypes': 'FILES_ASSUME_MIMETYPES',
|
|
||||||
'files.defaultDateFormat': 'FILES_DEFAULT_DATE_FORMAT',
|
|
||||||
'files.removeGpsMetadata': 'FILES_REMOVE_GPS_METADATA',
|
|
||||||
|
|
||||||
'urls.route': 'URLS_ROUTE',
|
|
||||||
'urls.length': 'URLS_LENGTH',
|
|
||||||
|
|
||||||
'datasource.type': 'DATASOURCE_TYPE',
|
'datasource.type': 'DATASOURCE_TYPE',
|
||||||
|
|
||||||
@@ -173,124 +151,174 @@ export const PROP_TO_ENV = {
|
|||||||
|
|
||||||
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
|
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
|
||||||
|
|
||||||
'features.imageCompression': 'FEATURES_IMAGE_COMPRESSION',
|
|
||||||
'features.robotsTxt': 'FEATURES_ROBOTS_TXT',
|
|
||||||
'features.healthcheck': 'FEATURES_HEALTHCHECK',
|
|
||||||
'features.userRegistration': 'FEATURES_USER_REGISTRATION',
|
|
||||||
'features.oauthRegistration': 'FEATURES_OAUTH_REGISTRATION',
|
|
||||||
'features.deleteOnMaxViews': 'FEATURES_DELETE_ON_MAX_VIEWS',
|
|
||||||
'features.thumbnails.enabled': 'FEATURES_THUMBNAILS_ENABLED',
|
|
||||||
'features.thumbnails.num_threads': 'FEATURES_THUMBNAILS_NUM_THREADS',
|
|
||||||
'features.metrics.enabled': 'FEATURES_METRICS_ENABLED',
|
|
||||||
'features.metrics.adminOnly': 'FEATURES_METRICS_ADMIN_ONLY',
|
|
||||||
'features.metrics.showUserSpecific': 'FEATURES_METRICS_SHOW_USER_SPECIFIC',
|
|
||||||
|
|
||||||
'invites.enabled': 'INVITES_ENABLED',
|
|
||||||
'invites.length': 'INVITES_LENGTH',
|
|
||||||
|
|
||||||
'website.title': 'WEBSITE_TITLE',
|
|
||||||
'website.titleLogo': 'WEBSITE_TITLE_LOGO',
|
|
||||||
'website.externalLinks': 'WEBSITE_EXTERNAL_LINKS',
|
|
||||||
'website.loginBackground': 'WEBSITE_LOGIN_BACKGROUND',
|
|
||||||
'website.defaultAvatar': 'WEBSITE_DEFAULT_AVATAR',
|
|
||||||
'website.tos': 'WEBSITE_TOS',
|
'website.tos': 'WEBSITE_TOS',
|
||||||
|
|
||||||
'website.theme.default': 'WEBSITE_THEME_DEFAULT',
|
// 'discord.onUpload.webhookUrl': 'DISCORD_ONUPLOAD_WEBHOOK_URL',
|
||||||
'website.theme.dark': 'WEBSITE_THEME_DARK',
|
// 'discord.onUpload.username': 'DISCORD_ONUPLOAD_USERNAME',
|
||||||
'website.theme.light': 'WEBSITE_THEME_LIGHT',
|
// 'discord.onUpload.avatarUrl': 'DISCORD_ONUPLOAD_AVATAR_URL',
|
||||||
|
// 'discord.onUpload.content': 'DISCORD_ONUPLOAD_CONTENT',
|
||||||
|
// 'discord.onUpload.embed.title': 'DISCORD_ONUPLOAD_EMBED_TITLE',
|
||||||
|
// 'discord.onUpload.embed.description': 'DISCORD_ONUPLOAD_EMBED_DESCRIPTION',
|
||||||
|
// 'discord.onUpload.embed.footer': 'DISCORD_ONUPLOAD_EMBED_FOOTER',
|
||||||
|
// 'discord.onUpload.embed.color': 'DISCORD_ONUPLOAD_EMBED_COLOR',
|
||||||
|
// 'discord.onUpload.embed.thumbnail': 'DISCORD_ONUPLOAD_EMBED_THUMBNAIL',
|
||||||
|
// 'discord.onUpload.embed.timestamp': 'DISCORD_ONUPLOAD_EMBED_TIMESTAMP',
|
||||||
|
// 'discord.onUpload.embed.imageOrVideo': 'DISCORD_ONUPLOAD_EMBED_IMAGE_OR_VIDEO',
|
||||||
|
// 'discord.onUpload.embed.url': 'DISCORD_ONUPLOAD_EMBED_URL',
|
||||||
|
|
||||||
'mfa.totp.enabled': 'MFA_TOTP_ENABLED',
|
// 'discord.onShorten.webhookUrl': 'DISCORD_ONSHORTEN_WEBHOOK_URL',
|
||||||
'mfa.totp.issuer': 'MFA_TOTP_ISSUER',
|
// 'discord.onShorten.username': 'DISCORD_ONSHORTEN_USERNAME',
|
||||||
'mfa.passkeys': 'MFA_PASSKEYS',
|
// 'discord.onShorten.avatarUrl': 'DISCORD_ONSHORTEN_AVATAR_URL',
|
||||||
|
// 'discord.onShorten.content': 'DISCORD_ONSHORTEN_CONTENT',
|
||||||
'oauth.bypassLocalLogin': 'OAUTH_BYPASS_LOCAL_LOGIN',
|
// 'discord.onShorten.embed.title': 'DISCORD_ONSHORTEN_EMBED_TITLE',
|
||||||
'oauth.loginOnly': 'OAUTH_LOGIN_ONLY',
|
// 'discord.onShorten.embed.description': 'DISCORD_ONSHORTEN_EMBED_DESCRIPTION',
|
||||||
'oauth.discord.clientId': 'OAUTH_DISCORD_CLIENT_ID',
|
// 'discord.onShorten.embed.footer': 'DISCORD_ONSHORTEN_EMBED_FOOTER',
|
||||||
'oauth.discord.clientSecret': 'OAUTH_DISCORD_CLIENT_SECRET',
|
// 'discord.onShorten.embed.color': 'DISCORD_ONSHORTEN_EMBED_COLOR',
|
||||||
'oauth.github.clientId': 'OAUTH_GITHUB_CLIENT_ID',
|
// 'discord.onShorten.embed.timestamp': 'DISCORD_ONSHORTEN_EMBED_TIMESTAMP',
|
||||||
'oauth.github.clientSecret': 'OAUTH_GITHUB_CLIENT_SECRET',
|
// 'discord.onShorten.embed.url': 'DISCORD_ONSHORTEN_EMBED_URL',
|
||||||
'oauth.google.clientId': 'OAUTH_GOOGLE_CLIENT_ID',
|
|
||||||
'oauth.google.clientSecret': 'OAUTH_GOOGLE_CLIENT_SECRET',
|
|
||||||
'oauth.oidc.clientId': 'OAUTH_OIDC_CLIENT_ID',
|
|
||||||
'oauth.oidc.clientSecret': 'OAUTH_OIDC_CLIENT_SECRET',
|
|
||||||
'oauth.oidc.authorizeUrl': 'OAUTH_OIDC_AUTHORIZE_URL',
|
|
||||||
'oauth.oidc.userinfoUrl': 'OAUTH_OIDC_USERINFO_URL',
|
|
||||||
'oauth.oidc.tokenUrl': 'OAUTH_OIDC_TOKEN_URL',
|
|
||||||
|
|
||||||
'discord.webhookUrl': 'DISCORD_WEBHOOK_URL',
|
|
||||||
'discord.username': 'DISCORD_USERNAME',
|
|
||||||
'discord.avatarUrl': 'DISCORD_AVATAR_URL',
|
|
||||||
|
|
||||||
'discord.onUpload.webhookUrl': 'DISCORD_ONUPLOAD_WEBHOOK_URL',
|
|
||||||
'discord.onUpload.username': 'DISCORD_ONUPLOAD_USERNAME',
|
|
||||||
'discord.onUpload.avatarUrl': 'DISCORD_ONUPLOAD_AVATAR_URL',
|
|
||||||
'discord.onUpload.content': 'DISCORD_ONUPLOAD_CONTENT',
|
|
||||||
'discord.onUpload.embed.title': 'DISCORD_ONUPLOAD_EMBED_TITLE',
|
|
||||||
'discord.onUpload.embed.description': 'DISCORD_ONUPLOAD_EMBED_DESCRIPTION',
|
|
||||||
'discord.onUpload.embed.footer': 'DISCORD_ONUPLOAD_EMBED_FOOTER',
|
|
||||||
'discord.onUpload.embed.color': 'DISCORD_ONUPLOAD_EMBED_COLOR',
|
|
||||||
'discord.onUpload.embed.thumbnail': 'DISCORD_ONUPLOAD_EMBED_THUMBNAIL',
|
|
||||||
'discord.onUpload.embed.timestamp': 'DISCORD_ONUPLOAD_EMBED_TIMESTAMP',
|
|
||||||
'discord.onUpload.embed.imageOrVideo': 'DISCORD_ONUPLOAD_EMBED_IMAGE_OR_VIDEO',
|
|
||||||
'discord.onUpload.embed.url': 'DISCORD_ONUPLOAD_EMBED_URL',
|
|
||||||
|
|
||||||
'discord.onShorten.webhookUrl': 'DISCORD_ONSHORTEN_WEBHOOK_URL',
|
|
||||||
'discord.onShorten.username': 'DISCORD_ONSHORTEN_USERNAME',
|
|
||||||
'discord.onShorten.avatarUrl': 'DISCORD_ONSHORTEN_AVATAR_URL',
|
|
||||||
'discord.onShorten.content': 'DISCORD_ONSHORTEN_CONTENT',
|
|
||||||
'discord.onShorten.embed.title': 'DISCORD_ONSHORTEN_EMBED_TITLE',
|
|
||||||
'discord.onShorten.embed.description': 'DISCORD_ONSHORTEN_EMBED_DESCRIPTION',
|
|
||||||
'discord.onShorten.embed.footer': 'DISCORD_ONSHORTEN_EMBED_FOOTER',
|
|
||||||
'discord.onShorten.embed.color': 'DISCORD_ONSHORTEN_EMBED_COLOR',
|
|
||||||
'discord.onShorten.embed.timestamp': 'DISCORD_ONSHORTEN_EMBED_TIMESTAMP',
|
|
||||||
'discord.onShorten.embed.url': 'DISCORD_ONSHORTEN_EMBED_URL',
|
|
||||||
|
|
||||||
'ratelimit.enabled': 'RATELIMIT_ENABLED',
|
|
||||||
'ratelimit.max': 'RATELIMIT_MAX',
|
|
||||||
'ratelimit.window': 'RATELIMIT_WINDOW',
|
|
||||||
'ratelimit.adminBypass': 'RATELIMIT_ADMIN_BYPASS',
|
|
||||||
'ratelimit.allowList': 'RATELIMIT_ALLOW_LIST',
|
|
||||||
|
|
||||||
'httpWebhook.onUpload': 'HTTP_WEBHOOK_ONUPLOAD',
|
|
||||||
'httpWebhook.onShorten': 'HTTP_WEBHOOK_ONSHORTEN',
|
|
||||||
|
|
||||||
'ssl.key': 'SSL_KEY',
|
'ssl.key': 'SSL_KEY',
|
||||||
'ssl.cert': 'SSL_CERT',
|
'ssl.cert': 'SSL_CERT',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DATABASE_TO_PROP = {
|
||||||
|
coreReturnHttpsUrls: 'core.returnHttpsUrls',
|
||||||
|
coreDefaultDomain: 'core.defaultDomain',
|
||||||
|
coreTempDirectory: 'core.tempDirectory',
|
||||||
|
|
||||||
|
chunksMax: 'chunks.max',
|
||||||
|
chunksSize: 'chunks.size',
|
||||||
|
chunksEnabled: 'chunks.enabled',
|
||||||
|
|
||||||
|
tasksDeleteInterval: 'tasks.deleteInterval',
|
||||||
|
tasksClearInvitesInterval: 'tasks.clearInvitesInterval',
|
||||||
|
tasksMaxViewsInterval: 'tasks.maxViewsInterval',
|
||||||
|
tasksThumbnailsInterval: 'tasks.thumbnailsInterval',
|
||||||
|
tasksMetricsInterval: 'tasks.metricsInterval',
|
||||||
|
|
||||||
|
filesRoute: 'files.route',
|
||||||
|
filesLength: 'files.length',
|
||||||
|
filesDefaultFormat: 'files.defaultFormat',
|
||||||
|
filesDisabledExtensions: 'files.disabledExtensions',
|
||||||
|
filesMaxFileSize: 'files.maxFileSize',
|
||||||
|
filesDefaultExpiration: 'files.defaultExpiration',
|
||||||
|
filesAssumeMimetypes: 'files.assumeMimetypes',
|
||||||
|
filesDefaultDateFormat: 'files.defaultDateFormat',
|
||||||
|
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
|
||||||
|
|
||||||
|
urlsRoute: 'urls.route',
|
||||||
|
urlsLength: 'urls.length',
|
||||||
|
|
||||||
|
featuresImageCompression: 'features.imageCompression',
|
||||||
|
featuresRobotsTxt: 'features.robotsTxt',
|
||||||
|
featuresHealthcheck: 'features.healthcheck',
|
||||||
|
featuresUserRegistration: 'features.userRegistration',
|
||||||
|
featuresOauthRegistration: 'features.oauthRegistration',
|
||||||
|
featuresDeleteOnMaxViews: 'features.deleteOnMaxViews',
|
||||||
|
|
||||||
|
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
|
||||||
|
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
|
||||||
|
|
||||||
|
featuresMetricsEnabled: 'features.metrics.enabled',
|
||||||
|
featuresMetricsAdminOnly: 'features.metrics.adminOnly',
|
||||||
|
featuresMetricsShowUserSpecific: 'features.metrics.showUserSpecific',
|
||||||
|
|
||||||
|
invitesEnabled: 'invites.enabled',
|
||||||
|
invitesLength: 'invites.length',
|
||||||
|
|
||||||
|
websiteTitle: 'website.title',
|
||||||
|
websiteTitleLogo: 'website.titleLogo',
|
||||||
|
websiteExternalLinks: 'website.externalLinks',
|
||||||
|
websiteLoginBackground: 'website.loginBackground',
|
||||||
|
websiteDefaultAvatar: 'website.defaultAvatar',
|
||||||
|
|
||||||
|
websiteThemeDefault: 'website.theme.default',
|
||||||
|
websiteThemeDark: 'website.theme.dark',
|
||||||
|
websiteThemeLight: 'website.theme.light',
|
||||||
|
|
||||||
|
oauthBypassLocalLogin: 'oauth.bypassLocalLogin',
|
||||||
|
oauthLoginOnly: 'oauth.loginOnly',
|
||||||
|
|
||||||
|
oauthDiscordClientId: 'oauth.discord.clientId',
|
||||||
|
oauthDiscordClientSecret: 'oauth.discord.clientSecret',
|
||||||
|
|
||||||
|
oauthGoogleClientId: 'oauth.google.clientId',
|
||||||
|
oauthGoogleClientSecret: 'oauth.google.clientSecret',
|
||||||
|
|
||||||
|
oauthGithubClientId: 'oauth.github.clientId',
|
||||||
|
oauthGithubClientSecret: 'oauth.github.clientSecret',
|
||||||
|
|
||||||
|
oauthOidcClientId: 'oauth.oidc.clientId',
|
||||||
|
oauthOidcClientSecret: 'oauth.oidc.clientSecret',
|
||||||
|
oauthOidcAuthorizeUrl: 'oauth.oidc.authorizeUrl',
|
||||||
|
oauthOidcUserinfoUrl: 'oauth.oidc.userinfoUrl',
|
||||||
|
oauthOidcTokenUrl: 'oauth.oidc.tokenUrl',
|
||||||
|
|
||||||
|
mfaTotpEnabled: 'mfa.totp.enabled',
|
||||||
|
mfaTotpIssuer: 'mfa.totp.issuer',
|
||||||
|
mfaPasskeys: 'mfa.passkeys',
|
||||||
|
|
||||||
|
ratelimitEnabled: 'ratelimit.enabled',
|
||||||
|
ratelimitMax: 'ratelimit.max',
|
||||||
|
ratelimitWindow: 'ratelimit.window',
|
||||||
|
ratelimitAdminBypass: 'ratelimit.adminBypass',
|
||||||
|
ratelimitAllowList: 'ratelimit.allowList',
|
||||||
|
|
||||||
|
httpWebhookOnUpload: 'httpWebhook.onUpload',
|
||||||
|
httpWebhookOnShorten: 'httpWebhook.onShorten',
|
||||||
|
|
||||||
|
discordWebhookUrl: 'discord.webhookUrl',
|
||||||
|
discordUsername: 'discord.username',
|
||||||
|
discordAvatarUrl: 'discord.avatarUrl',
|
||||||
|
|
||||||
|
discordOnUploadWebhookUrl: 'discord.onUpload.webhookUrl',
|
||||||
|
discordOnUploadUsername: 'discord.onUpload.username',
|
||||||
|
discordOnUploadAvatarUrl: 'discord.onUpload.avatarUrl',
|
||||||
|
discordOnUploadContent: 'discord.onUpload.content',
|
||||||
|
discordOnUploadEmbed: 'discord.onUpload.embed',
|
||||||
|
|
||||||
|
discordOnShortenWebhookUrl: 'discord.onShorten.webhookUrl',
|
||||||
|
discordOnShortenUsername: 'discord.onShorten.username',
|
||||||
|
discordOnShortenAvatarUrl: 'discord.onShorten.avatarUrl',
|
||||||
|
discordOnShortenContent: 'discord.onShorten.content',
|
||||||
|
discordOnShortenEmbed: 'discord.onShorten.embed',
|
||||||
|
};
|
||||||
|
|
||||||
const logger = log('config').c('read');
|
const logger = log('config').c('read');
|
||||||
|
|
||||||
|
export async function readDatabaseSettings() {
|
||||||
|
let ziplineTable = await prisma.zipline.findFirst({
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ziplineTable) {
|
||||||
|
ziplineTable = await prisma.zipline.create({
|
||||||
|
data: {
|
||||||
|
coreTempDirectory: join(tmpdir(), 'zipline'),
|
||||||
|
},
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ziplineTable;
|
||||||
|
}
|
||||||
|
|
||||||
export function readEnv() {
|
export function readEnv() {
|
||||||
const envs = [
|
const envs = [
|
||||||
env('core.port', 'number'),
|
env('core.port', 'number'),
|
||||||
env('core.hostname', 'string'),
|
env('core.hostname', 'string'),
|
||||||
env('core.secret', 'string'),
|
env('core.secret', 'string'),
|
||||||
env('core.databaseUrl', 'string'),
|
env('core.databaseUrl', 'string'),
|
||||||
env('core.returnHttpsUrls', 'boolean'),
|
|
||||||
env('core.defaultDomain', 'string'),
|
|
||||||
env('core.tempDirectory', 'string'),
|
|
||||||
|
|
||||||
env('chunks.max', 'byte'),
|
|
||||||
env('chunks.size', 'byte'),
|
|
||||||
env('chunks.enabled', 'boolean'),
|
|
||||||
|
|
||||||
env('tasks.deleteInterval', 'ms'),
|
|
||||||
env('tasks.clearInvitesInterval', 'ms'),
|
|
||||||
env('tasks.maxViewsInterval', 'ms'),
|
|
||||||
env('tasks.thumbnailsInterval', 'ms'),
|
|
||||||
env('tasks.metricsInterval', 'ms'),
|
|
||||||
|
|
||||||
env('files.route', 'string'),
|
|
||||||
env('files.length', 'number'),
|
|
||||||
env('files.defaultFormat', 'string'),
|
|
||||||
env('files.disabledExtensions', 'string[]'),
|
|
||||||
env('files.maxFileSize', 'byte'),
|
|
||||||
env('files.defaultExpiration', 'ms'),
|
|
||||||
env('files.assumeMimetypes', 'boolean'),
|
|
||||||
env('files.removeGpsMetadata', 'boolean'),
|
|
||||||
|
|
||||||
env('urls.route', 'string'),
|
|
||||||
env('urls.length', 'number'),
|
|
||||||
|
|
||||||
env('datasource.type', 'string'),
|
env('datasource.type', 'string'),
|
||||||
|
|
||||||
@@ -301,93 +329,13 @@ export function readEnv() {
|
|||||||
|
|
||||||
env('datasource.local.directory', 'string'),
|
env('datasource.local.directory', 'string'),
|
||||||
|
|
||||||
env('features.imageCompression', 'boolean'),
|
|
||||||
env('features.robotsTxt', 'boolean'),
|
|
||||||
env('features.healthcheck', 'boolean'),
|
|
||||||
env('features.userRegistration', 'boolean'),
|
|
||||||
env('features.oauthRegistration', 'boolean'),
|
|
||||||
env('features.deleteOnMaxViews', 'boolean'),
|
|
||||||
env('features.thumbnails.enabled', 'boolean'),
|
|
||||||
env('features.thumbnails.num_threads', 'number'),
|
|
||||||
env('features.metrics.enabled', 'boolean'),
|
|
||||||
env('features.metrics.adminOnly', 'boolean'),
|
|
||||||
env('features.metrics.showUserSpecific', 'boolean'),
|
|
||||||
|
|
||||||
env('invites.enabled', 'boolean'),
|
|
||||||
env('invites.length', 'number'),
|
|
||||||
|
|
||||||
env('website.title', 'string'),
|
|
||||||
env('website.titleLogo', 'string'),
|
|
||||||
env('website.externalLinks', 'json[]'),
|
|
||||||
env('website.loginBackground', 'string'),
|
|
||||||
env('website.defaultAvatar', 'string'),
|
|
||||||
env('website.tos', 'string'),
|
env('website.tos', 'string'),
|
||||||
|
|
||||||
env('website.theme.default', 'string'),
|
|
||||||
env('website.theme.dark', 'string'),
|
|
||||||
env('website.theme.light', 'string'),
|
|
||||||
|
|
||||||
env('mfa.totp.enabled', 'boolean'),
|
|
||||||
env('mfa.totp.issuer', 'string'),
|
|
||||||
env('mfa.passkeys', 'boolean'),
|
|
||||||
|
|
||||||
env('oauth.bypassLocalLogin', 'boolean'),
|
|
||||||
env('oauth.loginOnly', 'boolean'),
|
|
||||||
env('oauth.discord.clientId', 'string'),
|
|
||||||
env('oauth.discord.clientSecret', 'string'),
|
|
||||||
env('oauth.github.clientId', 'string'),
|
|
||||||
env('oauth.github.clientSecret', 'string'),
|
|
||||||
env('oauth.google.clientId', 'string'),
|
|
||||||
env('oauth.google.clientSecret', 'string'),
|
|
||||||
env('oauth.oidc.clientId', 'string'),
|
|
||||||
env('oauth.oidc.clientSecret', 'string'),
|
|
||||||
env('oauth.oidc.authorizeUrl', 'string'),
|
|
||||||
env('oauth.oidc.userinfoUrl', 'string'),
|
|
||||||
env('oauth.oidc.tokenUrl', 'string'),
|
|
||||||
|
|
||||||
env('discord.webhookUrl', 'string'),
|
|
||||||
env('discord.username', 'string'),
|
|
||||||
env('discord.avatarUrl', 'string'),
|
|
||||||
|
|
||||||
env('discord.onUpload.webhookUrl', 'string'),
|
|
||||||
env('discord.onUpload.username', 'string'),
|
|
||||||
env('discord.onUpload.avatarUrl', 'string'),
|
|
||||||
env('discord.onUpload.content', 'string'),
|
|
||||||
env('discord.onUpload.embed.title', 'string'),
|
|
||||||
env('discord.onUpload.embed.description', 'string'),
|
|
||||||
env('discord.onUpload.embed.footer', 'string'),
|
|
||||||
env('discord.onUpload.embed.color', 'string'),
|
|
||||||
env('discord.onUpload.embed.thumbnail', 'boolean'),
|
|
||||||
env('discord.onUpload.embed.timestamp', 'boolean'),
|
|
||||||
env('discord.onUpload.embed.imageOrVideo', 'boolean'),
|
|
||||||
env('discord.onUpload.embed.url', 'boolean'),
|
|
||||||
|
|
||||||
env('discord.onShorten.webhookUrl', 'string'),
|
|
||||||
env('discord.onShorten.username', 'string'),
|
|
||||||
env('discord.onShorten.avatarUrl', 'string'),
|
|
||||||
env('discord.onShorten.content', 'string'),
|
|
||||||
env('discord.onShorten.embed.title', 'string'),
|
|
||||||
env('discord.onShorten.embed.description', 'string'),
|
|
||||||
env('discord.onShorten.embed.footer', 'string'),
|
|
||||||
env('discord.onShorten.embed.color', 'string'),
|
|
||||||
env('discord.onShorten.embed.timestamp', 'boolean'),
|
|
||||||
env('discord.onShorten.embed.url', 'boolean'),
|
|
||||||
|
|
||||||
env('ratelimit.enabled', 'boolean'),
|
|
||||||
env('ratelimit.max', 'number'),
|
|
||||||
env('ratelimit.window', 'ms'),
|
|
||||||
env('ratelimit.adminBypass', 'boolean'),
|
|
||||||
env('ratelimit.allowList', 'string[]'),
|
|
||||||
|
|
||||||
env('httpWebhook.onUpload', 'string'),
|
|
||||||
env('httpWebhook.onShorten', 'string'),
|
|
||||||
|
|
||||||
env('ssl.key', 'string'),
|
env('ssl.key', 'string'),
|
||||||
env('ssl.cert', 'string'),
|
env('ssl.cert', 'string'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// clone raw
|
const raw: Record<keyof typeof rawConfig, any> = {};
|
||||||
const raw = structuredClone(rawConfig);
|
|
||||||
|
|
||||||
for (let i = 0; i !== envs.length; ++i) {
|
for (let i = 0; i !== envs.length; ++i) {
|
||||||
const env = envs[i];
|
const env = envs[i];
|
||||||
@@ -401,23 +349,49 @@ export function readEnv() {
|
|||||||
|
|
||||||
if (env.variable === 'DATASOURCE_TYPE') {
|
if (env.variable === 'DATASOURCE_TYPE') {
|
||||||
if (value === 's3') {
|
if (value === 's3') {
|
||||||
raw.datasource.s3 = {
|
raw['datasource.s3.accessKeyId'] = undefined;
|
||||||
accessKeyId: undefined,
|
raw['datasource.s3.secretAccessKey'] = undefined;
|
||||||
secretAccessKey: undefined,
|
raw['datasource.s3.region'] = undefined;
|
||||||
region: undefined,
|
raw['datasource.s3.bucket'] = undefined;
|
||||||
bucket: undefined,
|
|
||||||
};
|
|
||||||
} else if (value === 'local') {
|
} else if (value === 'local') {
|
||||||
raw.datasource.local = {
|
raw['datasource.local.directory'] = undefined;
|
||||||
directory: undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parse(value, env.type);
|
const parsed = parse(value, env.type);
|
||||||
if (parsed === undefined) continue;
|
if (parsed === undefined) continue;
|
||||||
|
|
||||||
setDotProp(raw, env.property, parsed);
|
raw[env.property] = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function read() {
|
||||||
|
const database = await readDatabaseSettings();
|
||||||
|
const env = readEnv();
|
||||||
|
|
||||||
|
const raw = structuredClone(rawConfig);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(database as Record<string, any>)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
logger.warn('Missing database value', { key });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP]) continue;
|
||||||
|
if (value == undefined) continue;
|
||||||
|
|
||||||
|
setProperty(raw, DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
logger.warn('Missing env value', { key });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperty(raw, key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return raw;
|
return raw;
|
||||||
@@ -431,24 +405,6 @@ function env(property: keyof typeof PROP_TO_ENV, type: EnvType) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDotProp(obj: Record<string, any>, property: string, value: unknown) {
|
|
||||||
const parts = property.split('.');
|
|
||||||
const last = parts.pop()!;
|
|
||||||
|
|
||||||
for (let i = 0; i !== parts.length; ++i) {
|
|
||||||
const part = parts[i];
|
|
||||||
const next = obj[part];
|
|
||||||
|
|
||||||
if (typeof next === 'object' && next !== null) {
|
|
||||||
obj = next;
|
|
||||||
} else {
|
|
||||||
obj = obj[part] = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
obj[last] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(value: string, type: EnvType) {
|
function parse(value: string, type: EnvType) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'string':
|
case 'string':
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { config } from '.';
|
|
||||||
import enabled from '../oauth/enabled';
|
import enabled from '../oauth/enabled';
|
||||||
import { Config } from './validate';
|
import { Config } from './validate';
|
||||||
|
|
||||||
@@ -11,9 +10,10 @@ export type SafeConfig = Omit<
|
|||||||
bypassLocalLogin: boolean;
|
bypassLocalLogin: boolean;
|
||||||
loginOnly: boolean;
|
loginOnly: boolean;
|
||||||
};
|
};
|
||||||
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function safeConfig(): SafeConfig {
|
export function safeConfig(config: Config): SafeConfig {
|
||||||
const { datasource: _d, core: _c, oauth, discord: _di, ratelimit: _r, httpWebhook: _h, ...rest } = config;
|
const { datasource: _d, core: _c, oauth, discord: _di, ratelimit: _r, httpWebhook: _h, ...rest } = config;
|
||||||
|
|
||||||
(rest as SafeConfig).oauthEnabled = enabled(config);
|
(rest as SafeConfig).oauthEnabled = enabled(config);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ZodError, ZodIssue, z } from 'zod';
|
import { ZodError, ZodIssue, z } from 'zod';
|
||||||
import { PROP_TO_ENV, ParsedEnv } from './read';
|
import { PROP_TO_ENV, ParsedConfig } from './read';
|
||||||
import { log } from '../logger';
|
import { log } from '../logger';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { bytes } from '../bytes';
|
import { bytes } from '../bytes';
|
||||||
@@ -303,7 +303,7 @@ export type Config = z.infer<typeof schema>;
|
|||||||
|
|
||||||
const logger = log('config').c('validate');
|
const logger = log('config').c('validate');
|
||||||
|
|
||||||
export function validateEnv(env: ParsedEnv): Config {
|
export function validateConfigObject(env: ParsedConfig): Config {
|
||||||
const building = !!process.env.ZIPLINE_BUILD;
|
const building = !!process.env.ZIPLINE_BUILD;
|
||||||
|
|
||||||
if (building) {
|
if (building) {
|
||||||
@@ -320,7 +320,7 @@ export function validateEnv(env: ParsedEnv): Config {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`environment validated: ${JSON.stringify(validated)}`);
|
logger.debug('reloaded config');
|
||||||
|
|
||||||
return validated;
|
return validated;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ declare global {
|
|||||||
var __datasource__: Datasource;
|
var __datasource__: Datasource;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!global.__datasource__) {
|
function getDatasource(conf?: typeof config): void {
|
||||||
|
if (!conf) return;
|
||||||
|
|
||||||
const logger = log('datasource');
|
const logger = log('datasource');
|
||||||
|
|
||||||
switch (config.datasource.type) {
|
switch (config.datasource.type) {
|
||||||
case 'local':
|
case 'local':
|
||||||
global.__datasource__ = new LocalDatasource(config.datasource.local!.directory);
|
datasource = global.__datasource__ = new LocalDatasource(config.datasource.local!.directory);
|
||||||
break;
|
break;
|
||||||
case 's3':
|
case 's3':
|
||||||
global.__datasource__ = new S3Datasource({
|
datasource = global.__datasource__ = new S3Datasource({
|
||||||
accessKeyId: config.datasource.s3!.accessKeyId,
|
accessKeyId: config.datasource.s3!.accessKeyId,
|
||||||
secretAccessKey: config.datasource.s3!.secretAccessKey,
|
secretAccessKey: config.datasource.s3!.secretAccessKey,
|
||||||
region: config.datasource.s3?.region,
|
region: config.datasource.s3?.region,
|
||||||
@@ -35,4 +37,8 @@ if (!global.__datasource__) {
|
|||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
datasource = global.__datasource__;
|
datasource = global.__datasource__;
|
||||||
|
|
||||||
export { datasource };
|
if (!global.__datasource__ && !datasource) {
|
||||||
|
getDatasource(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { datasource, getDatasource };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { config as libConfig, reloadSettings } from '@/lib/config';
|
||||||
import { SafeConfig, safeConfig } from '@/lib/config/safe';
|
import { SafeConfig, safeConfig } from '@/lib/config/safe';
|
||||||
import { ZiplineTheme } from '@/lib/theme';
|
import { ZiplineTheme } from '@/lib/theme';
|
||||||
import { readThemes } from '@/lib/theme/file';
|
import { readThemes } from '@/lib/theme/file';
|
||||||
@@ -13,8 +14,9 @@ export function withSafeConfig<T = unknown>(
|
|||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const config = safeConfig();
|
if (!libConfig) await reloadSettings();
|
||||||
|
|
||||||
|
const config = safeConfig(libConfig);
|
||||||
const data = await fn(ctx, config);
|
const data = await fn(ctx, config);
|
||||||
|
|
||||||
if ((data as any) && (data as any).notFound)
|
if ((data as any) && (data as any).notFound)
|
||||||
|
|||||||
22
src/pages/dashboard/admin/settings.tsx
Normal file
22
src/pages/dashboard/admin/settings.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Layout from '@/components/Layout';
|
||||||
|
import DashboardServerSettings from '@/components/pages/serverSettings';
|
||||||
|
import useLogin from '@/lib/hooks/useLogin';
|
||||||
|
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
|
||||||
|
import { isAdministrator } from '@/lib/role';
|
||||||
|
import { LoadingOverlay } from '@mantine/core';
|
||||||
|
import { InferGetServerSidePropsType } from 'next';
|
||||||
|
|
||||||
|
export default function DashboardIndex({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
|
const { loading, user } = useLogin(true);
|
||||||
|
if (loading) return <LoadingOverlay visible />;
|
||||||
|
|
||||||
|
if (!isAdministrator(user?.role)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout config={config}>
|
||||||
|
<DashboardServerSettings />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = withSafeConfig();
|
||||||
45
src/pages/reload.tsx
Normal file
45
src/pages/reload.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { reloadSettings } from '@/lib/config';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { isAdministrator } from '@/lib/role';
|
||||||
|
import { getSession } from '@/server/session';
|
||||||
|
import { GetServerSideProps } from 'next';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Serves as a "reload config", since we have to reload the config on next.js as well as the server.
|
||||||
|
This takes care of the next.js side.
|
||||||
|
|
||||||
|
It can be called by fetch('/reload') to do it manually/after saving settings.
|
||||||
|
After that you have to reload the page to see the changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function Reload() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
|
const session = await getSession(ctx.req, ctx.res);
|
||||||
|
if (!session.id || !session.sessionId)
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/auth/login',
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
sessions: {
|
||||||
|
has: session.sessionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return { notFound: true };
|
||||||
|
if (!isAdministrator(user.role)) return { notFound: true };
|
||||||
|
|
||||||
|
await reloadSettings();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { readEnv } from '@/lib/config/read';
|
import { reloadSettings } from '@/lib/config';
|
||||||
import { validateEnv } from '@/lib/config/validate';
|
import { getDatasource } from '@/lib/datasource';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { runMigrations } from '@/lib/db/migration';
|
import { runMigrations } from '@/lib/db/migration';
|
||||||
import { log } from '@/lib/logger';
|
import { log } from '@/lib/logger';
|
||||||
|
import { notNull } from '@/lib/primitive';
|
||||||
|
import { isAdministrator } from '@/lib/role';
|
||||||
import { Tasks } from '@/lib/tasks';
|
import { Tasks } from '@/lib/tasks';
|
||||||
import clearInvites from '@/lib/tasks/run/clearInvites';
|
import clearInvites from '@/lib/tasks/run/clearInvites';
|
||||||
import deleteFiles from '@/lib/tasks/run/deleteFiles';
|
import deleteFiles from '@/lib/tasks/run/deleteFiles';
|
||||||
@@ -24,8 +26,6 @@ import next, { ALL_METHODS } from './plugins/next';
|
|||||||
import loadRoutes from './routes';
|
import loadRoutes from './routes';
|
||||||
import { filesRoute } from './routes/files.dy';
|
import { filesRoute } from './routes/files.dy';
|
||||||
import { urlsRoute } from './routes/urls.dy';
|
import { urlsRoute } from './routes/urls.dy';
|
||||||
import { isAdministrator } from '@/lib/role';
|
|
||||||
import { notNull } from '@/lib/primitive';
|
|
||||||
|
|
||||||
const MODE = process.env.NODE_ENV || 'production';
|
const MODE = process.env.NODE_ENV || 'production';
|
||||||
const logger = log('server');
|
const logger = log('server');
|
||||||
@@ -42,8 +42,11 @@ BigInt.prototype.toJSON = function () {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
logger.info('starting zipline', { mode: MODE, version: version });
|
logger.info('starting zipline', { mode: MODE, version: version });
|
||||||
logger.info('reading environment for configuration');
|
logger.info('reading settings...');
|
||||||
const config = validateEnv(readEnv());
|
await reloadSettings();
|
||||||
|
|
||||||
|
const config = global.__config__;
|
||||||
|
getDatasource(config);
|
||||||
|
|
||||||
if (config.datasource.type === 'local') {
|
if (config.datasource.type === 'local') {
|
||||||
await mkdir(config.datasource.local!.directory, { recursive: true });
|
await mkdir(config.datasource.local!.directory, { recursive: true });
|
||||||
@@ -170,6 +173,18 @@ async function main() {
|
|||||||
} else done(null, body);
|
} else done(null, body);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.setErrorHandler((error, req, res) => {
|
||||||
|
logger.error(error);
|
||||||
|
|
||||||
|
if (error.statusCode) {
|
||||||
|
res.status(error.statusCode);
|
||||||
|
res.send({ error: error.message, statusCode: error.statusCode });
|
||||||
|
} else {
|
||||||
|
res.status(500);
|
||||||
|
res.send({ error: 'Internal Server Error', statusCode: 500, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await server.listen({
|
await server.listen({
|
||||||
port: config.core.port,
|
port: config.core.port,
|
||||||
host: config.core.hostname,
|
host: config.core.hostname,
|
||||||
|
|||||||
302
src/server/routes/api/server/settings.ts
Normal file
302
src/server/routes/api/server/settings.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { bytes } from '@/lib/bytes';
|
||||||
|
import { reloadSettings } from '@/lib/config';
|
||||||
|
import { readDatabaseSettings } from '@/lib/config/read';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { readThemes } from '@/lib/theme/file';
|
||||||
|
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||||
|
import { userMiddleware } from '@/server/middleware/user';
|
||||||
|
import fastifyPlugin from 'fastify-plugin';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { cpus } from 'os';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type Settings = Awaited<ReturnType<typeof readDatabaseSettings>>;
|
||||||
|
|
||||||
|
export type ApiServerSettingsResponse = Settings;
|
||||||
|
type Body = Partial<Settings>;
|
||||||
|
|
||||||
|
const discordEmbed = z
|
||||||
|
.union([
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
title: z.string().nullable().default(null),
|
||||||
|
description: z.string().nullable().default(null),
|
||||||
|
footer: z.string().nullable().default(null),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})$/)
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
|
thumbnail: z.boolean().default(false),
|
||||||
|
imageOrVideo: z.boolean().default(false),
|
||||||
|
timestamp: z.boolean().default(false),
|
||||||
|
url: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.transform((value) => (Object.keys(value || {}).length ? value : null)),
|
||||||
|
z.string(),
|
||||||
|
])
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => (typeof value === 'string' ? JSON.parse(value) : value))
|
||||||
|
.transform((value) =>
|
||||||
|
typeof value === 'object' ? (Object.keys(value || {}).length ? value : null) : value,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PATH = '/api/server/settings';
|
||||||
|
export default fastifyPlugin(
|
||||||
|
(server, _, done) => {
|
||||||
|
server.get<{ Body: Body }>(
|
||||||
|
PATH,
|
||||||
|
{
|
||||||
|
preHandler: [userMiddleware, administratorMiddleware],
|
||||||
|
},
|
||||||
|
async (_, res) => {
|
||||||
|
const settings = await prisma.zipline.findFirst({
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return res.notFound('no settings table found');
|
||||||
|
|
||||||
|
return res.send(settings);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.patch<{ Body: Body }>(
|
||||||
|
PATH,
|
||||||
|
{
|
||||||
|
preHandler: [userMiddleware, administratorMiddleware],
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = await prisma.zipline.findFirst();
|
||||||
|
if (!settings) return res.notFound('no settings table found');
|
||||||
|
|
||||||
|
const themes = (await readThemes()).map((x) => x.id);
|
||||||
|
|
||||||
|
const settingsBodySchema = z
|
||||||
|
.object({
|
||||||
|
coreTempDirectory: z
|
||||||
|
.string()
|
||||||
|
.refine((dir) => !dir || existsSync(dir), 'temp directory does not exist'),
|
||||||
|
coreDefaultDomain: z.string().nullable(),
|
||||||
|
coreReturnHttpsUrls: z.boolean(),
|
||||||
|
|
||||||
|
chunksEnabled: z.boolean(),
|
||||||
|
chunksMax: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? bytes(value) : value)),
|
||||||
|
chunksSize: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? bytes(value) : value)),
|
||||||
|
|
||||||
|
tasksDeleteInterval: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
tasksClearInvitesInterval: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
tasksMaxViewsInterval: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
tasksThumbnailsInterval: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
tasksMetricsInterval: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
|
||||||
|
filesRoute: z.string().startsWith('/'),
|
||||||
|
filesLength: z.number().min(1).max(64),
|
||||||
|
filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']),
|
||||||
|
filesDisabledExtensions: z
|
||||||
|
.union([z.array(z.string()), z.string()])
|
||||||
|
.transform((value) =>
|
||||||
|
typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value,
|
||||||
|
),
|
||||||
|
filesMaxFileSize: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? bytes(value) : value)),
|
||||||
|
filesDefaultExpiration: z
|
||||||
|
.union([z.number(), z.string()])
|
||||||
|
.nullable()
|
||||||
|
.transform((value) => (typeof value === 'string' ? ms(value) : value)),
|
||||||
|
filesAssumeMimetypes: z.boolean(),
|
||||||
|
filesDefaultDateFormat: z.string(),
|
||||||
|
filesRemoveGpsMetadata: z.boolean(),
|
||||||
|
|
||||||
|
urlsRoute: z.string().startsWith('/'),
|
||||||
|
urlsLength: z.number().min(1).max(64),
|
||||||
|
|
||||||
|
featuresImageCompression: z.boolean(),
|
||||||
|
featuresRobotsTxt: z.boolean(),
|
||||||
|
featuresHealthcheck: z.boolean(),
|
||||||
|
featuresUserRegistration: z.boolean(),
|
||||||
|
featuresOauthRegistration: z.boolean(),
|
||||||
|
featuresDeleteOnMaxViews: z.boolean(),
|
||||||
|
|
||||||
|
featuresThumbnailsEnabled: z.boolean(),
|
||||||
|
featuresThumbnailsNumberThreads: z.number().min(1).max(cpus().length),
|
||||||
|
|
||||||
|
featuresMetricsEnabled: z.boolean(),
|
||||||
|
featuresMetricsAdminOnly: z.boolean(),
|
||||||
|
feaeturesMetricsShowUserSpecific: z.boolean(),
|
||||||
|
|
||||||
|
invitesEnabled: z.boolean(),
|
||||||
|
invitesLength: z.number().min(1).max(64),
|
||||||
|
|
||||||
|
websiteTitle: z.string(),
|
||||||
|
websiteTitleLogo: z.string().url().nullable(),
|
||||||
|
websiteExternalLinks: z
|
||||||
|
.union([
|
||||||
|
z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string().url(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
z.string(),
|
||||||
|
])
|
||||||
|
.transform((value) => (typeof value === 'string' ? JSON.parse(value) : value)),
|
||||||
|
websiteLoginBackground: z.string().url().nullable(),
|
||||||
|
websiteDefaultAvatar: z.string().url().nullable(),
|
||||||
|
|
||||||
|
websiteThemeDefault: z.enum(['system', ...themes]),
|
||||||
|
websiteThemeDark: z.enum(themes as unknown as readonly [string, ...string[]]),
|
||||||
|
websiteThemeLight: z.enum(themes as unknown as readonly [string, ...string[]]),
|
||||||
|
|
||||||
|
oauthBypassLocalLogin: z.boolean(),
|
||||||
|
oauthLoginOnly: z.boolean(),
|
||||||
|
|
||||||
|
oauthDiscordClientId: z.string().nullable(),
|
||||||
|
oauthDiscordClientSecret: z.string().nullable(),
|
||||||
|
oauthGoogleClientId: z.string().nullable(),
|
||||||
|
oauthGoogleClientSecret: z.string().nullable(),
|
||||||
|
oauthGithubClientId: z.string().nullable(),
|
||||||
|
oauthGithubClientSecret: z.string().nullable(),
|
||||||
|
oauthOidcClientId: z.string().nullable(),
|
||||||
|
oauthOidcClientSecret: z.string().nullable(),
|
||||||
|
oauthOidcAuthorizeUrl: z.string().url().nullable(),
|
||||||
|
oauthOidcTokenUrl: z.string().url().nullable(),
|
||||||
|
oauthOidcUserinfoUrl: z.string().url().nullable(),
|
||||||
|
|
||||||
|
mfaTotpEnabled: z.boolean(),
|
||||||
|
mfaTotpIssuer: z.string(),
|
||||||
|
mfaPasskeys: z.boolean(),
|
||||||
|
|
||||||
|
ratelimitEnabled: z.boolean(),
|
||||||
|
ratelimitMax: z.number(),
|
||||||
|
ratelimitWindow: z.number().nullable(),
|
||||||
|
ratelimitAdminBypass: z.boolean(),
|
||||||
|
ratelimitAllowList: z
|
||||||
|
.union([z.array(z.string()), z.string()])
|
||||||
|
.transform((value) => (typeof value === 'string' ? value.split(',') : value)),
|
||||||
|
|
||||||
|
httpWebhookOnUpload: z.string().url().nullable(),
|
||||||
|
httpWebhookOnShorten: z.string().url().nullable(),
|
||||||
|
|
||||||
|
discordWebhookUrl: z.string().url().nullable(),
|
||||||
|
discordUsername: z.string().nullable(),
|
||||||
|
discordAvatarUrl: z.string().url().nullable(),
|
||||||
|
|
||||||
|
discordOnUploadWebhookUrl: z.string().url().nullable(),
|
||||||
|
discordOnUploadUsername: z.string().nullable(),
|
||||||
|
discordOnUploadAvatarUrl: z.string().url().nullable(),
|
||||||
|
discordOnUploadContent: z.string().nullable(),
|
||||||
|
discordOnUploadEmbed: discordEmbed,
|
||||||
|
|
||||||
|
discordOnShortenWebhookUrl: z.string().url().nullable(),
|
||||||
|
discordOnShortenUsername: z.string().nullable(),
|
||||||
|
discordOnShortenAvatarUrl: z.string().url().nullable(),
|
||||||
|
discordOnShortenContent: z.string().nullable(),
|
||||||
|
discordOnShortenEmbed: discordEmbed,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(!data.oauthDiscordClientId || data.oauthDiscordClientSecret) &&
|
||||||
|
(!data.oauthDiscordClientSecret || data.oauthDiscordClientId),
|
||||||
|
{
|
||||||
|
message: 'discord oauth fields are incomplete',
|
||||||
|
path: ['oauthDiscordClientId', 'oauthDiscordClientSecret'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(!data.oauthGoogleClientId || data.oauthGoogleClientSecret) &&
|
||||||
|
(!data.oauthGoogleClientSecret || data.oauthGoogleClientId),
|
||||||
|
{
|
||||||
|
message: 'google oauth fields are incomplete',
|
||||||
|
path: ['oauthGoogleClientId', 'oauthGoogleClientSecret'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(!data.oauthGithubClientId || data.oauthGithubClientSecret) &&
|
||||||
|
(!data.oauthGithubClientSecret || data.oauthGithubClientId),
|
||||||
|
{
|
||||||
|
message: 'github oauth fields are incomplete',
|
||||||
|
path: ['oauthGithubClientId', 'oauthGithubClientSecret'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) =>
|
||||||
|
(!data.oauthOidcClientId &&
|
||||||
|
!data.oauthOidcClientSecret &&
|
||||||
|
!data.oauthOidcAuthorizeUrl &&
|
||||||
|
!data.oauthOidcTokenUrl &&
|
||||||
|
!data.oauthOidcUserinfoUrl) ||
|
||||||
|
(data.oauthOidcClientId &&
|
||||||
|
data.oauthOidcClientSecret &&
|
||||||
|
data.oauthOidcAuthorizeUrl &&
|
||||||
|
data.oauthOidcTokenUrl &&
|
||||||
|
data.oauthOidcUserinfoUrl),
|
||||||
|
{
|
||||||
|
message: 'oidc oauth fields are incomplete',
|
||||||
|
path: [
|
||||||
|
'oauthOidcClientId',
|
||||||
|
'oauthOidcClientSecret',
|
||||||
|
'oauthOidcAuthorizeUrl',
|
||||||
|
'oauthOidcTokenUrl',
|
||||||
|
'oauthOidcUserinfoUrl',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = settingsBodySchema.safeParse(req.body);
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).send({
|
||||||
|
statusCode: 400,
|
||||||
|
issues: result.error.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSettings = await prisma.zipline.update({
|
||||||
|
where: {
|
||||||
|
id: settings.id,
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
data: {
|
||||||
|
...result.data,
|
||||||
|
},
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await reloadSettings();
|
||||||
|
|
||||||
|
return res.send(newSettings);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
done();
|
||||||
|
},
|
||||||
|
{ name: PATH },
|
||||||
|
);
|
||||||
@@ -30,7 +30,9 @@ const logger = log('api').c('upload');
|
|||||||
export const PATH = '/api/upload';
|
export const PATH = '/api/upload';
|
||||||
export default fastifyPlugin(
|
export default fastifyPlugin(
|
||||||
(server, _, done) => {
|
(server, _, done) => {
|
||||||
const rateLimit = server.rateLimit();
|
const rateLimit = server.rateLimit
|
||||||
|
? server.rateLimit()
|
||||||
|
: (_req: any, _res: any, next: () => any) => next();
|
||||||
|
|
||||||
server.post<{
|
server.post<{
|
||||||
Headers: UploadHeaders;
|
Headers: UploadHeaders;
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ const logger = log('api').c('user').c('urls');
|
|||||||
|
|
||||||
export default fastifyPlugin(
|
export default fastifyPlugin(
|
||||||
(server, _, done) => {
|
(server, _, done) => {
|
||||||
const rateLimit = server.rateLimit();
|
const rateLimit = server.rateLimit
|
||||||
|
? server.rateLimit()
|
||||||
|
: (_req: any, _res: any, next: () => any) => next();
|
||||||
|
|
||||||
server.post<{ Body: Body; Headers: Headers }>(
|
server.post<{ Body: Body; Headers: Headers }>(
|
||||||
PATH,
|
PATH,
|
||||||
|
|||||||
Reference in New Issue
Block a user