mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 07:40:45 -08:00
feat: database based server settings
This commit is contained in:
@@ -37,9 +37,9 @@
|
||||
"@mantine/hooks": "^7.2.2",
|
||||
"@mantine/modals": "^7.2.2",
|
||||
"@mantine/notifications": "^7.2.2",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@prisma/internals": "^5.6.0",
|
||||
"@prisma/migrate": "^5.6.0",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"@prisma/internals": "^5.19.1",
|
||||
"@prisma/migrate": "^5.19.1",
|
||||
"@tabler/icons-react": "^2.42.0",
|
||||
"@xoi/gps-metadata-remover": "^1.1.2",
|
||||
"argon2": "^0.30.3",
|
||||
@@ -48,6 +48,7 @@
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^12.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dot-prop": "^9.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -64,7 +65,7 @@
|
||||
"multer": "1.4.5-lts.1",
|
||||
"next": "^14.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^5.6.0",
|
||||
"prisma": "^5.19.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^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 {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["omitApi"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -13,6 +14,107 @@ model Zipline {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
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 {
|
||||
@@ -113,7 +215,7 @@ model File {
|
||||
updatedAt DateTime @updatedAt
|
||||
deletesAt DateTime?
|
||||
|
||||
name String // name & file saved on datasource
|
||||
name String // name & file saved on datasource
|
||||
originalName String? // original name of file when uploaded
|
||||
size BigInt
|
||||
type String
|
||||
@@ -233,4 +335,4 @@ model Invite {
|
||||
|
||||
inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
inviterId String
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
IconLink,
|
||||
IconLogout,
|
||||
IconRefreshDot,
|
||||
IconSettingsExclamation,
|
||||
IconSettingsFilled,
|
||||
IconShieldLockFilled,
|
||||
IconTags,
|
||||
@@ -114,6 +115,12 @@ const navLinks: NavLinks[] = [
|
||||
if: (user) => isAdministrator(user?.role),
|
||||
active: (path: string) => path.startsWith('/dashboard/admin'),
|
||||
links: [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <IconSettingsFilled size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin/settings',
|
||||
href: '/dashboard/admin/settings',
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
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' />
|
||||
)}
|
||||
|
||||
<Title fw={700}>Zipline</Title>
|
||||
<Title fw={700}>{config.website.title.trim()}</Title>
|
||||
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Menu shadow='md' width={200}>
|
||||
@@ -275,6 +282,16 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
||||
Settings
|
||||
</Menu.Item>
|
||||
|
||||
{isAdministrator(user?.role) && (
|
||||
<Menu.Item
|
||||
leftSection={<IconSettingsExclamation size='1rem' />}
|
||||
component={Link}
|
||||
href='/dashboard/admin/settings'
|
||||
>
|
||||
Server Settings
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
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 { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
|
||||
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 { ApiStatsResponse } from '@/server/routes/api/stats';
|
||||
import { ApiUploadResponse } from '@/server/routes/api/upload';
|
||||
@@ -69,6 +70,7 @@ export type Response = {
|
||||
'/api/server/clear_temp': ApiServerClearTempResponse;
|
||||
'/api/server/clear_zeros': ApiServerClearZerosResponse;
|
||||
'/api/server/requery_size': ApiServerRequerySizeResponse;
|
||||
'/api/server/settings': ApiServerSettingsResponse;
|
||||
'/api/healthcheck': ApiHealthcheckResponse;
|
||||
'/api/setup': ApiSetupResponse;
|
||||
'/api/upload': ApiUploadResponse;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readEnv } from './read';
|
||||
import { validateEnv, Config } from './validate';
|
||||
import { read } from './read';
|
||||
import { validateConfigObject, Config } from './validate';
|
||||
|
||||
let config: Config;
|
||||
|
||||
@@ -8,11 +8,10 @@ declare global {
|
||||
var __config__: Config;
|
||||
}
|
||||
|
||||
if (!global.__config__) {
|
||||
global.__config__ = validateEnv(readEnv());
|
||||
}
|
||||
const reloadSettings = async () => {
|
||||
config = global.__config__ = validateConfigObject((await read()) as any);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
config = global.__config__;
|
||||
|
||||
export { config };
|
||||
export { config, reloadSettings };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import msFn from 'ms';
|
||||
import { log } from '../logger';
|
||||
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[]';
|
||||
|
||||
export type ParsedEnv = ReturnType<typeof readEnv>;
|
||||
export type ParsedConfig = ReturnType<typeof read>;
|
||||
|
||||
export const rawConfig: any = {
|
||||
core: {
|
||||
@@ -133,32 +137,6 @@ export const PROP_TO_ENV = {
|
||||
'core.hostname': 'CORE_HOSTNAME',
|
||||
'core.secret': 'CORE_SECRET',
|
||||
'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',
|
||||
|
||||
@@ -173,124 +151,174 @@ export const PROP_TO_ENV = {
|
||||
|
||||
'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.theme.default': 'WEBSITE_THEME_DEFAULT',
|
||||
'website.theme.dark': 'WEBSITE_THEME_DARK',
|
||||
'website.theme.light': 'WEBSITE_THEME_LIGHT',
|
||||
// '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',
|
||||
|
||||
'mfa.totp.enabled': 'MFA_TOTP_ENABLED',
|
||||
'mfa.totp.issuer': 'MFA_TOTP_ISSUER',
|
||||
'mfa.passkeys': 'MFA_PASSKEYS',
|
||||
|
||||
'oauth.bypassLocalLogin': 'OAUTH_BYPASS_LOCAL_LOGIN',
|
||||
'oauth.loginOnly': 'OAUTH_LOGIN_ONLY',
|
||||
'oauth.discord.clientId': 'OAUTH_DISCORD_CLIENT_ID',
|
||||
'oauth.discord.clientSecret': 'OAUTH_DISCORD_CLIENT_SECRET',
|
||||
'oauth.github.clientId': 'OAUTH_GITHUB_CLIENT_ID',
|
||||
'oauth.github.clientSecret': 'OAUTH_GITHUB_CLIENT_SECRET',
|
||||
'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',
|
||||
// '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',
|
||||
|
||||
'ssl.key': 'SSL_KEY',
|
||||
'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');
|
||||
|
||||
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() {
|
||||
const envs = [
|
||||
env('core.port', 'number'),
|
||||
env('core.hostname', 'string'),
|
||||
env('core.secret', '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'),
|
||||
|
||||
@@ -301,93 +329,13 @@ export function readEnv() {
|
||||
|
||||
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.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.cert', 'string'),
|
||||
];
|
||||
|
||||
// clone raw
|
||||
const raw = structuredClone(rawConfig);
|
||||
const raw: Record<keyof typeof rawConfig, any> = {};
|
||||
|
||||
for (let i = 0; i !== envs.length; ++i) {
|
||||
const env = envs[i];
|
||||
@@ -401,23 +349,49 @@ export function readEnv() {
|
||||
|
||||
if (env.variable === 'DATASOURCE_TYPE') {
|
||||
if (value === 's3') {
|
||||
raw.datasource.s3 = {
|
||||
accessKeyId: undefined,
|
||||
secretAccessKey: undefined,
|
||||
region: undefined,
|
||||
bucket: undefined,
|
||||
};
|
||||
raw['datasource.s3.accessKeyId'] = undefined;
|
||||
raw['datasource.s3.secretAccessKey'] = undefined;
|
||||
raw['datasource.s3.region'] = undefined;
|
||||
raw['datasource.s3.bucket'] = undefined;
|
||||
} else if (value === 'local') {
|
||||
raw.datasource.local = {
|
||||
directory: undefined,
|
||||
};
|
||||
raw['datasource.local.directory'] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parse(value, env.type);
|
||||
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;
|
||||
@@ -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) {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { config } from '.';
|
||||
import enabled from '../oauth/enabled';
|
||||
import { Config } from './validate';
|
||||
|
||||
@@ -11,9 +10,10 @@ export type SafeConfig = Omit<
|
||||
bypassLocalLogin: 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;
|
||||
|
||||
(rest as SafeConfig).oauthEnabled = enabled(config);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { join, resolve } from 'path';
|
||||
import { bytes } from '../bytes';
|
||||
@@ -303,7 +303,7 @@ export type Config = z.infer<typeof schema>;
|
||||
|
||||
const logger = log('config').c('validate');
|
||||
|
||||
export function validateEnv(env: ParsedEnv): Config {
|
||||
export function validateConfigObject(env: ParsedConfig): Config {
|
||||
const building = !!process.env.ZIPLINE_BUILD;
|
||||
|
||||
if (building) {
|
||||
@@ -320,7 +320,7 @@ export function validateEnv(env: ParsedEnv): Config {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.debug(`environment validated: ${JSON.stringify(validated)}`);
|
||||
logger.debug('reloaded config');
|
||||
|
||||
return validated;
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,15 +11,17 @@ declare global {
|
||||
var __datasource__: Datasource;
|
||||
}
|
||||
|
||||
if (!global.__datasource__) {
|
||||
function getDatasource(conf?: typeof config): void {
|
||||
if (!conf) return;
|
||||
|
||||
const logger = log('datasource');
|
||||
|
||||
switch (config.datasource.type) {
|
||||
case 'local':
|
||||
global.__datasource__ = new LocalDatasource(config.datasource.local!.directory);
|
||||
datasource = global.__datasource__ = new LocalDatasource(config.datasource.local!.directory);
|
||||
break;
|
||||
case 's3':
|
||||
global.__datasource__ = new S3Datasource({
|
||||
datasource = global.__datasource__ = new S3Datasource({
|
||||
accessKeyId: config.datasource.s3!.accessKeyId,
|
||||
secretAccessKey: config.datasource.s3!.secretAccessKey,
|
||||
region: config.datasource.s3?.region,
|
||||
@@ -35,4 +37,8 @@ if (!global.__datasource__) {
|
||||
// eslint-disable-next-line prefer-const
|
||||
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 { ZiplineTheme } from '@/lib/theme';
|
||||
import { readThemes } from '@/lib/theme/file';
|
||||
@@ -13,8 +14,9 @@ export function withSafeConfig<T = unknown>(
|
||||
}
|
||||
> {
|
||||
return async (ctx) => {
|
||||
const config = safeConfig();
|
||||
if (!libConfig) await reloadSettings();
|
||||
|
||||
const config = safeConfig(libConfig);
|
||||
const data = await fn(ctx, config);
|
||||
|
||||
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 { validateEnv } from '@/lib/config/validate';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { getDatasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { runMigrations } from '@/lib/db/migration';
|
||||
import { log } from '@/lib/logger';
|
||||
import { notNull } from '@/lib/primitive';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { Tasks } from '@/lib/tasks';
|
||||
import clearInvites from '@/lib/tasks/run/clearInvites';
|
||||
import deleteFiles from '@/lib/tasks/run/deleteFiles';
|
||||
@@ -24,8 +26,6 @@ import next, { ALL_METHODS } from './plugins/next';
|
||||
import loadRoutes from './routes';
|
||||
import { filesRoute } from './routes/files.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 logger = log('server');
|
||||
@@ -42,8 +42,11 @@ BigInt.prototype.toJSON = function () {
|
||||
|
||||
async function main() {
|
||||
logger.info('starting zipline', { mode: MODE, version: version });
|
||||
logger.info('reading environment for configuration');
|
||||
const config = validateEnv(readEnv());
|
||||
logger.info('reading settings...');
|
||||
await reloadSettings();
|
||||
|
||||
const config = global.__config__;
|
||||
getDatasource(config);
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
await mkdir(config.datasource.local!.directory, { recursive: true });
|
||||
@@ -170,6 +173,18 @@ async function main() {
|
||||
} 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({
|
||||
port: config.core.port,
|
||||
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 default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
const rateLimit = server.rateLimit();
|
||||
const rateLimit = server.rateLimit
|
||||
? server.rateLimit()
|
||||
: (_req: any, _res: any, next: () => any) => next();
|
||||
|
||||
server.post<{
|
||||
Headers: UploadHeaders;
|
||||
|
||||
@@ -42,7 +42,9 @@ const logger = log('api').c('user').c('urls');
|
||||
|
||||
export default fastifyPlugin(
|
||||
(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 }>(
|
||||
PATH,
|
||||
|
||||
Reference in New Issue
Block a user