feat: database based server settings

This commit is contained in:
diced
2024-09-12 15:54:38 -07:00
parent 382e4d69d6
commit 641ec235d9
32 changed files with 2623 additions and 1969 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

@@ -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'

View 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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 });
}
};
}

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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':

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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)

View 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
View 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: {},
};
};

View File

@@ -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,

View 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 },
);

View File

@@ -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;

View File

@@ -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,