This commit is contained in:
Ethan Conaway 2025-05-02 21:01:36 +03:00 committed by GitHub
commit b5cabfb55f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1970 additions and 882 deletions

2
.gitignore vendored
View file

@ -45,4 +45,4 @@ next-env.d.ts
# zipline
uploads*/
*.crt
*.key
*.key

View file

@ -1,5 +1,5 @@
import { Response } from '@/lib/api/response';
import { Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
import { Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
import useSWR from 'swr';
import dynamic from 'next/dynamic';
@ -93,3 +93,42 @@ export default function DashboardSettings() {
</>
);
}
export function EnvTooltip(
props: React.PropsWithChildren<{
envVar: string;
data: any;
varKey: string;
}>,
) {
const state = checkPropSafe(props);
const enabled = state !== false;
return (
<Tooltip
label={
enabled
? `WARNING: The ${props.envVar} environment variable takes priority over this value. Currently "${state}"`
: ''
}
color='red'
events={{
hover: enabled,
focus: false,
touch: false,
}}
>
<div>{props.children}</div>
</Tooltip>
);
}
function checkPropSafe(props: any): boolean | string {
const data = props.data;
if (data === undefined) return false;
const locked = data.locked;
if (locked === undefined) return false;
const val = locked[props.varKey];
if (val === undefined) return false;
return val;
}

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsChunks({
swr: { data, isLoading },
@ -39,29 +40,35 @@ export default function ServerSettingsChunks({
<Title order={2}>Chunks</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
<EnvTooltip envVar='CHUNKS_ENABLED' data={data} varKey='chunksEnabled'>
<Switch
mt='md'
label='Enable Chunks'
description='Enable chunked uploads.'
{...form.getInputProps('chunksEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Max Chunk Size'
description='Maximum size of an upload before it is split into chunks.'
placeholder='95mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksMax')}
/>
<EnvTooltip envVar='CHUNKS_MAX' data={data} varKey='chunksMax'>
<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')}
/>
</EnvTooltip>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
<EnvTooltip envVar='CHUNKS_SIZE' data={data} varKey='chunksSize'>
<TextInput
label='Chunk Size'
description='Size of each chunk.'
placeholder='25mb'
disabled={!form.values.chunksEnabled}
{...form.getInputProps('chunksSize')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsCore({
swr: { data, isLoading },
@ -49,27 +50,33 @@ export default function ServerSettingsCore({
<Title order={2}>Core</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
<EnvTooltip envVar='CORE_RETURN_HTTPS_URLS' data={data} varKey='coreReturnHttpsUrls'>
<Switch
mt='md'
label='Return HTTPS URLs'
description='Return URLs with HTTPS protocol.'
{...form.getInputProps('coreReturnHttpsUrls', { type: 'checkbox' })}
/>
</EnvTooltip>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
<EnvTooltip envVar='CORE_DEFAULT_DOMAIN' data={data} varKey='coreDefaultDomain'>
<TextInput
label='Default Domain'
description='The domain to use when generating URLs. This value should not include the protocol.'
placeholder='example.com'
{...form.getInputProps('coreDefaultDomain')}
/>
</EnvTooltip>
<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')}
/>
<EnvTooltip envVar='CORE_TEMP_DIRECTORY' data={data} varKey='coreTempDirectory'>
<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')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -16,6 +16,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
type DiscordEmbed = Record<string, any>;
@ -169,27 +170,33 @@ export default function ServerSettingsDiscord({
<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')}
/>
<EnvTooltip envVar='DISCORD_WEBHOOK_URL' data={data} varKey='discordWebhookUrl'>
<TextInput
mt='md'
label='Webhook URL'
description='The Discord webhook URL to send notifications to'
placeholder='https://discord.com/api/webhooks/...'
{...formMain.getInputProps('discordWebhookUrl')}
/>
</EnvTooltip>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Username'
description='The username to send notifications as'
{...formMain.getInputProps('discordUsername')}
/>
<EnvTooltip envVar='DISCORD_USERNAME' data={data} varKey='discordUsername'>
<TextInput
label='Username'
description='The username to send notifications as'
{...formMain.getInputProps('discordUsername')}
/>
</EnvTooltip>
<TextInput
label='Avatar URL'
description='The avatar for the webhook'
placeholder='https://example.com/avatar.png'
{...formMain.getInputProps('discordAvatarUrl')}
/>
<EnvTooltip envVar='DISCORD_AVATAR_URL' data={data} varKey='discordAvatarUrl'>
<TextInput
label='Avatar URL'
description='The avatar for the webhook'
placeholder='https://example.com/avatar.png'
{...formMain.getInputProps('discordAvatarUrl')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
@ -202,98 +209,110 @@ export default function ServerSettingsDiscord({
<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/...'
{...formOnUpload.getInputProps('discordOnUploadWebhookUrl')}
/>
<EnvTooltip envVar='DISCORD_ON_UPLOAD_WEBHOOK_URL' data={data} varKey='discordOnUploadWebhookUrl'>
<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/...'
{...formOnUpload.getInputProps('discordOnUploadWebhookUrl')}
/>
</EnvTooltip>
<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'
{...formOnUpload.getInputProps('discordOnUploadUsername')}
/>
<EnvTooltip envVar='DISCORD_ON_UPLOAD_USERNAME' data={data} varKey='discordOnUploadUsername'>
<TextInput
label='Username'
description='The username to send notifications as. If this is left blank, the main username will be used'
{...formOnUpload.getInputProps('discordOnUploadUsername')}
/>
</EnvTooltip>
<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'
{...formOnUpload.getInputProps('discordOnUploadAvatarUrl')}
/>
<EnvTooltip envVar='DISCORD_ON_UPLOAD_AVATAR_URL' data={data} varKey='discordOnUploadAvatarUrl'>
<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'
{...formOnUpload.getInputProps('discordOnUploadAvatarUrl')}
/>
</EnvTooltip>
</SimpleGrid>
<Textarea
mt='md'
label='Content'
description='The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out'
minRows={1}
maxRows={7}
{...formOnUpload.getInputProps('discordOnUploadContent')}
/>
<EnvTooltip envVar='DISCORD_ON_UPLOAD_CONTENT' data={data} varKey='discordOnUploadContent'>
<Textarea
mt='md'
label='Content'
description='The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out'
minRows={1}
maxRows={7}
{...formOnUpload.getInputProps('discordOnUploadContent')}
/>
</EnvTooltip>
<Switch
mt='md'
label='Embed'
description='Send the notification as an embed. This will allow for more customization below.'
{...formOnUpload.getInputProps('discordOnUploadEmbed', { type: 'checkbox' })}
/>
<EnvTooltip envVar='DISCORD_ON_UPLOAD_EMBED' data={data} varKey='discordOnUploadEmbed'>
<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')}
/>
<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='Description'
description='The description of the embed'
{...formOnUpload.getInputProps('discordOnUploadEmbedDescription')}
/>
<TextInput
label='Footer'
description='The footer of the embed'
{...formOnUpload.getInputProps('discordOnUploadEmbedFooter')}
/>
<TextInput
label='Footer'
description='The footer of the embed'
{...formOnUpload.getInputProps('discordOnUploadEmbedFooter')}
/>
<ColorInput
label='Color'
description='The color of the embed'
{...formOnUpload.getInputProps('discordOnUploadEmbedColor')}
/>
<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='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='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='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>
<Switch
label='URL'
description='Makes the title clickable and links to the URL of the file'
{...formOnUpload.getInputProps('discordOnUploadEmbedUrl', { type: 'checkbox' })}
/>
</SimpleGrid>
</Paper>
</Collapse>
</EnvTooltip>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
@ -305,86 +324,104 @@ export default function ServerSettingsDiscord({
<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/...'
{...formOnShorten.getInputProps('discordOnShortenWebhookUrl')}
/>
<EnvTooltip
envVar='DISCORD_ON_SHORTEN_WEBHOOK_URL'
data={data}
varKey='discordOnShortenWebhookUrl'
>
<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/...'
{...formOnShorten.getInputProps('discordOnShortenWebhookUrl')}
/>
</EnvTooltip>
<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'
{...formOnShorten.getInputProps('discordOnShortenUsername')}
/>
<EnvTooltip envVar='DISCORD_ON_SHORTEN_USERNAME' data={data} varKey='discordOnShortenUsername'>
<TextInput
label='Username'
description='The username to send notifications as. If this is left blank, the main username will be used'
{...formOnShorten.getInputProps('discordOnShortenUsername')}
/>
</EnvTooltip>
<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'
{...formOnShorten.getInputProps('discordOnShortenAvatarUrl')}
/>
<EnvTooltip
envVar='DISCORD_ON_SHORTEN_AVATAR_URL'
data={data}
varKey='discordOnShortenAvatarUrl'
>
<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'
{...formOnShorten.getInputProps('discordOnShortenAvatarUrl')}
/>
</EnvTooltip>
</SimpleGrid>
<Textarea
mt='md'
label='Content'
description='The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out'
minRows={1}
maxRows={7}
{...formOnShorten.getInputProps('discordOnShortenContent')}
/>
<EnvTooltip envVar='DISCORD_ON_SHORTEN_CONTENT' data={data} varKey='discordOnShortenContent'>
<Textarea
mt='md'
label='Content'
description='The content of the notification. This can be blank, but at least one of the content or embed fields must be filled out'
minRows={1}
maxRows={7}
{...formOnShorten.getInputProps('discordOnShortenContent')}
/>
</EnvTooltip>
<Switch
mt='md'
label='Embed'
description='Send the notification as an embed. This will allow for more customization below.'
{...formOnShorten.getInputProps('discordOnShortenEmbed', { type: 'checkbox' })}
/>
<EnvTooltip envVar='DISCORD_ON_SHORTEN_EMBED' data={data} varKey='discordOnShortenEmbed'>
<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')}
/>
<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='Description'
description='The description of the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedDescription')}
/>
<TextInput
label='Footer'
description='The footer of the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedFooter')}
/>
<TextInput
label='Footer'
description='The footer of the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedFooter')}
/>
<ColorInput
label='Color'
description='The color of the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedColor')}
/>
<ColorInput
label='Color'
description='The color of the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedColor')}
/>
<Switch
label='Timestamp'
description='Show the timestamp in the embed'
{...formOnShorten.getInputProps('discordOnShortenEmbedTimestamp', { 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>
<Switch
label='URL'
description='Makes the title clickable and links to the URL of the file'
{...formOnShorten.getInputProps('discordOnShortenEmbedUrl', { type: 'checkbox' })}
/>
</SimpleGrid>
</Paper>
</Collapse>
</EnvTooltip>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsFeatures({
swr: { data, isLoading },
@ -54,74 +55,104 @@ export default function ServerSettingsFeatures({
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_IMAGE_COMPRESSION' data={data} varKey='featuresImageCompression'>
<Switch
label='Image Compression'
description='Allows the ability for users to compress images.'
{...form.getInputProps('featuresImageCompression', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='/robots.txt'
description='Enables a robots.txt file for search engine optimization. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_ROBOTS_TXT' data={data} varKey='featuresRobotsTxt'>
<Switch
label='/robots.txt'
description='Enables a robots.txt file for search engine optimization. Requires a server restart.'
{...form.getInputProps('featuresRobotsTxt', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_HEALTHCHECK' data={data} varKey='featuresHealthcheck'>
<Switch
label='Healthcheck'
description='Enables a healthcheck route for uptime monitoring. Requires a server restart.'
{...form.getInputProps('featuresHealthcheck', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_USER_REGISTRATION' data={data} varKey='featuresUserRegistration'>
<Switch
label='User Registration'
description='Allows users to register an account on the server.'
{...form.getInputProps('featuresUserRegistration', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_OAUTH_REGISTRATION' data={data} varKey='featuresOauthRegistration'>
<Switch
label='OAuth Registration'
description='Allows users to register an account using OAuth providers.'
{...form.getInputProps('featuresOauthRegistration', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_DELETE_ON_MAX_VIEWS' data={data} varKey='featuresDeleteOnMaxViews'>
<Switch
label='Delete on Max Views'
description='Automatically deletes files/urls after they reach the maximum view count. Requires a server restart.'
{...form.getInputProps('featuresDeleteOnMaxViews', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_METRICS_ENABLED' data={data} varKey='featuresMetricsEnabled'>
<Switch
label='Enable Metrics'
description='Enables metrics for the server. Requires a server restart.'
{...form.getInputProps('featuresMetricsEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_METRICS_ADMIN_ONLY' data={data} varKey='featuresMetricsAdminOnly'>
<Switch
label='Admin Only Metrics'
description='Requires an administrator to view metrics.'
{...form.getInputProps('featuresMetricsAdminOnly', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<EnvTooltip
envVar='FEATURES_METRICS_SHOW_USER_SPECIFIC'
data={data}
varKey='featuresMetricsShowUserSpecific'
>
<Switch
label='Show User Specific Metrics'
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FEATURES_THUMBNAILS_ENABLED' data={data} varKey='featuresThumbnailsEnabled'>
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
{...form.getInputProps('featuresThumbnailsEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<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}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<EnvTooltip
envVar='FEATURES_THUMBNAILS_NUMBER_THREADS'
data={data}
varKey='featuresThumbnailsNumberThreads'
>
<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}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -15,6 +15,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsFiles({
swr: { data, isLoading },
@ -103,83 +104,109 @@ export default function ServerSettingsFiles({
<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')}
/>
<EnvTooltip envVar='FILES_ROUTE' data={data} varKey='filesRoute'>
<TextInput
label='Route'
description='The route to use for file uploads. Requires a server restart.'
placeholder='/u'
{...form.getInputProps('filesRoute')}
/>
</EnvTooltip>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
<EnvTooltip envVar='FILES_LENGTH' data={data} varKey='filesLength'>
<NumberInput
label='Length'
description='The length of the file name (for randomly generated names).'
min={1}
max={64}
{...form.getInputProps('filesLength')}
/>
</EnvTooltip>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FILES_ASSUME_MIMETYPES' data={data} varKey='filesAssumeMimetypes'>
<Switch
label='Assume Mimetypes'
description='Assume the mimetype of a file for its extension.'
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
<EnvTooltip envVar='FILES_REMOVE_GPS_METADATA' data={data} varKey='filesRemoveGpsMetadata'>
<Switch
label='Remove GPS Metadata'
description='Remove GPS metadata from files.'
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
/>
</EnvTooltip>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
<EnvTooltip envVar='FILES_DEFAULT_FORMAT' data={data} varKey='filesDefaultFormat'>
<Select
label='Default Format'
description='The default format to use for file names.'
placeholder='random'
data={['random', 'date', 'uuid', 'name', 'gfycat']}
{...form.getInputProps('filesDefaultFormat')}
/>
</EnvTooltip>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
<EnvTooltip envVar='FILES_DISABLED_EXTENSIONS' data={data} varKey='filesDisabledExtensions'>
<TextInput
label='Disabled Extensions'
description='Extensions to disable, separated by commas.'
placeholder='exe, bat, sh'
{...form.getInputProps('filesDisabledExtensions')}
/>
</EnvTooltip>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
<EnvTooltip envVar='FILES_MAX_FILE_SIZE' data={data} varKey='filesMaxFileSize'>
<TextInput
label='Max File Size'
description='The maximum file size allowed.'
placeholder='100mb'
{...form.getInputProps('filesMaxFileSize')}
/>
</EnvTooltip>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
<EnvTooltip envVar='FILES_DEFAULT_EXPIRATION' data={data} varKey='filesDefaultExpiration'>
<TextInput
label='Default Expiration'
description='The default expiration time for files.'
placeholder='30d'
{...form.getInputProps('filesDefaultExpiration')}
/>
</EnvTooltip>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
<EnvTooltip envVar='FILES_DEFAULT_DATE_FORMAT' data={data} varKey='filesDefaultDateFormat'>
<TextInput
label='Default Date Format'
description='The default date format to use.'
placeholder='YYYY-MM-DD_HH:mm:ss'
{...form.getInputProps('filesDefaultDateFormat')}
/>
</EnvTooltip>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
<EnvTooltip
envVar='FILES_RANDOM_WORDS_NUM_ADJECTIVES'
data={data}
varKey='filesRandomWordsNumAdjectives'
>
<NumberInput
label='Random Words Num Adjectives'
description='The number of adjectives to use for the random-words/gfycat format.'
min={1}
max={10}
{...form.getInputProps('filesRandomWordsNumAdjectives')}
/>
</EnvTooltip>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
<EnvTooltip envVar='FILES_RANDOM_WORDS_SEPARATOR' data={data} varKey='filesRandomWordsSeparator'>
<TextInput
label='Random Words Separator'
description='The separator to use for the random-words/gfycat format.'
placeholder='-'
{...form.getInputProps('filesRandomWordsSeparator')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsHttpWebhook({
swr: { data, isLoading },
@ -50,19 +51,23 @@ export default function ServerSettingsHttpWebhook({
<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')}
/>
<EnvTooltip envVar='HTTP_WEBHOOK_ON_UPLOAD' data={data} varKey='httpWebhookOnUpload'>
<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')}
/>
</EnvTooltip>
<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')}
/>
<EnvTooltip envVar='HTTP_WEBHOOK_ON_SHORTEN' data={data} varKey='httpWebhookOnShorten'>
<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')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsInvites({
swr: { data, isLoading },
@ -38,21 +39,25 @@ export default function ServerSettingsInvites({
<form onSubmit={form.onSubmit(onSubmit)}>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
<EnvTooltip envVar='INVITES_ENABLED' data={data} varKey='invitesEnabled'>
<Switch
label='Enable Invites'
description='Enable the use of invite links to register new users.'
{...form.getInputProps('invitesEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
disabled={!form.values.invitesEnabled}
{...form.getInputProps('invitesLength')}
/>
<EnvTooltip envVar='INVITES_LENGTH' data={data} varKey='invitesLength'>
<NumberInput
label='Length'
description='The length of the invite code.'
placeholder='6'
min={1}
max={64}
disabled={!form.values.invitesEnabled}
{...form.getInputProps('invitesLength')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsMfa({
swr: { data, isLoading },
@ -40,23 +41,30 @@ export default function ServerSettingsMfa({
<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 keys, etc.'
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
/>
<EnvTooltip envVar='MFA_TOTP_ENABLED' data={data} varKey='mfaTotpEnabled'>
<Switch
label='Passkeys'
description='Enable the use of passwordless login with the use of WebAuthn passkeys like your phone, security keys, etc.'
{...form.getInputProps('mfaPasskeys', { type: 'checkbox' })}
/>
</EnvTooltip>
<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')}
/>
<EnvTooltip envVar='MFA_TOTP_ENABLED' data={data} varKey='mfaTotpEnabled'>
<Switch
label='Enable TOTP'
description='Enable Time-based One-Time Passwords with the use of an authenticator app.'
{...form.getInputProps('mfaTotpEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<EnvTooltip envVar='MFA_TOTP_ISSUER' data={data} varKey='mfaTotpIssuer'>
<TextInput
label='Issuer'
description='The issuer to use for the TOTP token.'
placeholder='Zipline'
{...form.getInputProps('mfaTotpIssuer')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -15,6 +15,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsOauth({
swr: { data, isLoading },
@ -107,17 +108,21 @@ export default function ServerSettingsOauth({
<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' })}
/>
<EnvTooltip envVar='OAUTH_BYPASS_LOCAL_LOGIN' data={data} varKey='oauthBypassLocalLogin'>
<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' })}
/>
</EnvTooltip>
<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' })}
/>
<EnvTooltip envVar='OAUTH_LOGIN_ONLY' data={data} varKey='oauthLoginOnly'>
<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' })}
/>
</EnvTooltip>
</SimpleGrid>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
@ -127,13 +132,21 @@ export default function ServerSettingsOauth({
</Title>
</Anchor>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
<EnvTooltip envVar='OAUTH_DISCORD_CLIENT_ID' data={data} varKey='oauthDiscordClientId'>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_DISCORD_CLIENT_SECRET' data={data} varKey='oauthDiscordClientSecret'>
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_DISCORD_REDIRECT_URI' data={data} varKey='oauthDiscordRedirectUri'>
<TextInput
label='Discord Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</EnvTooltip>
</Paper>
<Paper withBorder p='sm'>
<Anchor href='https://console.developers.google.com/' target='_blank'>
@ -142,13 +155,21 @@ export default function ServerSettingsOauth({
</Title>
</Anchor>
<TextInput label='Google Client ID' {...form.getInputProps('oauthGoogleClientId')} />
<TextInput label='Google Client Secret' {...form.getInputProps('oauthGoogleClientSecret')} />
<TextInput
label='Google Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthGoogleRedirectUri')}
/>
<EnvTooltip envVar='OAUTH_GOOGLE_CLIENT_ID' data={data} varKey='oauthGoogleClientId'>
<TextInput label='Google Client ID' {...form.getInputProps('oauthGoogleClientId')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_GOOGLE_CLIENT_SECRET' data={data} varKey='oauthGoogleClientSecret'>
<TextInput label='Google Client Secret' {...form.getInputProps('oauthGoogleClientSecret')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_GOOGLE_REDIRECT_URI' data={data} varKey='oauthGoogleRedirectUri'>
<TextInput
label='Google Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthGoogleRedirectUri')}
/>
</EnvTooltip>
</Paper>
</SimpleGrid>
@ -160,13 +181,21 @@ export default function ServerSettingsOauth({
</Anchor>
<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')} />
<TextInput
label='GitHub Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthGithubRedirectUri')}
/>
<EnvTooltip envVar='OAUTH_GITHUB_CLIENT_ID' data={data} varKey='oauthGithubClientId'>
<TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_GITHUB_CLIENT_SECRET' data={data} varKey='oauthGithubClientSecret'>
<TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_GITHUB_REDIRECT_URI' data={data} varKey='oauthGithubRedirectUri'>
<TextInput
label='GitHub Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthGithubRedirectUri')}
/>
</EnvTooltip>
</SimpleGrid>
</Paper>
@ -174,16 +203,33 @@ export default function ServerSettingsOauth({
<Title order={4}>OpenID Connect</Title>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
<TextInput
label='OIDC Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthOidcRedirectUri')}
/>
<EnvTooltip envVar='OAUTH_OIDC_CLIENT_ID' data={data} varKey='oauthOidcClientId'>
<TextInput label='OIDC Client ID' {...form.getInputProps('oauthOidcClientId')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_OIDC_CLIENT_SECRET' data={data} varKey='oauthOidcClientSecret'>
<TextInput label='OIDC Client Secret' {...form.getInputProps('oauthOidcClientSecret')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_OIDC_AUTHORIZE_URL' data={data} varKey='oauthOidcAuthorizeUrl'>
<TextInput label='OIDC Authorize URL' {...form.getInputProps('oauthOidcAuthorizeUrl')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_OIDC_TOKEN_URL' data={data} varKey='oauthOidcTokenUrl'>
<TextInput label='OIDC Token URL' {...form.getInputProps('oauthOidcTokenUrl')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_OIDC_USERINFO_URL' data={data} varKey='oauthOidcUserinfoUrl'>
<TextInput label='OIDC Userinfo URL' {...form.getInputProps('oauthOidcUserinfoUrl')} />
</EnvTooltip>
<EnvTooltip envVar='OAUTH_OIDC_REDIRECT_URI' data={data} varKey='oauthOidcRedirectUri'>
<TextInput
label='OIDC Redirect URL'
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
{...form.getInputProps('oauthOidcRedirectUri')}
/>
</EnvTooltip>
</SimpleGrid>
</Paper>

View file

@ -16,6 +16,7 @@ import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsPWA({
swr: { data, isLoading },
@ -74,53 +75,65 @@ export default function ServerSettingsPWA({
</Text>
<form onSubmit={form.onSubmit(onSubmit)}>
<Switch
mt='md'
label='PWA Enabled'
description='Allow users to install the Zipline PWA on their devices.'
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
/>
<EnvTooltip envVar='PWA_ENABLED' data={data} varKey='pwaEnabled'>
<Switch
mt='md'
label='PWA Enabled'
description='Allow users to install the Zipline PWA on their devices.'
{...form.getInputProps('pwaEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput
label='Title'
description='The title for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaTitle')}
/>
<EnvTooltip envVar='PWA_TITLE' data={data} varKey='pwaTitle'>
<TextInput
label='Title'
description='The title for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaTitle')}
/>
</EnvTooltip>
<TextInput
label='Short Name'
description='The short name for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaShortName')}
/>
<EnvTooltip envVar='PWA_SHORT_NAME' data={data} varKey='pwaShortName'>
<TextInput
label='Short Name'
description='The short name for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaShortName')}
/>
</EnvTooltip>
<TextInput
label='Description'
description='The description for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaDescription')}
/>
<EnvTooltip envVar='PWA_DESCRIPTION' data={data} varKey='pwaDescription'>
<TextInput
label='Description'
description='The description for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaDescription')}
/>
</EnvTooltip>
<ColorInput
label='Theme Color'
description='The theme color for the PWA'
placeholder='#000000'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaThemeColor')}
/>
<EnvTooltip envVar='PWA_THEME_COLOR' data={data} varKey='pwaThemeColor'>
<ColorInput
label='Theme Color'
description='The theme color for the PWA'
placeholder='#000000'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaThemeColor')}
/>
</EnvTooltip>
<ColorInput
label='Background Color'
description='The background color for the PWA'
placeholder='#ffffff'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaBackgroundColor')}
/>
<EnvTooltip envVar='PWA_BACKGROUND_COLOR' data={data} varKey='pwaBackgroundColor'>
<ColorInput
label='Background Color'
description='The background color for the PWA'
placeholder='#ffffff'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaBackgroundColor')}
/>
</EnvTooltip>
</SimpleGrid>
<Group mt='md'>

View file

@ -15,6 +15,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsRatelimit({
swr: { data, isLoading },
@ -82,47 +83,69 @@ export default function ServerSettingsRatelimit({
<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' })}
/>
<EnvTooltip envVar='RATELIMIT_ENABLED' data={data} varKey='ratelimitEnabled'>
<Switch
label='Enable Ratelimit'
description='Enable ratelimiting for the server.'
{...form.getInputProps('ratelimitEnabled', { type: 'checkbox' })}
/>
</EnvTooltip>
<Switch
label='Admin Bypass'
description='Allow admins to bypass the ratelimit.'
disabled={!form.values.ratelimitEnabled}
{...form.getInputProps('ratelimitAdminBypass', { type: 'checkbox' })}
/>
<EnvTooltip envVar='RATELIMIT_ADMIN_BYPASS' data={data} varKey='ratelimitAdminBypass'>
<Switch
label='Admin Bypass'
description='Allow admins to bypass the ratelimit.'
disabled={!form.values.ratelimitEnabled}
{...form.getInputProps('ratelimitAdminBypass', { type: 'checkbox' })}
/>
</EnvTooltip>
<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')}
/>
<EnvTooltip envVar='RATELIMIT_MAX' data={data} varKey='ratelimitMax'>
<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')}
/>
</EnvTooltip>
<NumberInput
label='Window'
description='The window in seconds to allow the max requests.'
placeholder='60'
min={1}
disabled={!form.values.ratelimitEnabled}
{...form.getInputProps('ratelimitWindow')}
/>
<EnvTooltip envVar='RATELIMIT_WINDOW' data={data} varKey='ratelimitWindow'>
<NumberInput
label='Window'
description='The window in seconds to allow the max requests.'
placeholder='60'
min={1}
disabled={!form.values.ratelimitEnabled}
{...form.getInputProps('ratelimitWindow')}
/>
</EnvTooltip>
<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')}
/>
<EnvTooltip envVar='RATELIMIT_ALLOW_LIST' data={data} varKey='ratelimitAllowList'>
<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')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
<Button
type='submit'
mt='md'
loading={isLoading}
disabled={
data?.locked['ratelimitEnabled'] &&
data?.locked['ratelimitMax'] &&
data?.locked['ratelimitWindow'] &&
data?.locked['ratelimitAdminBypass'] &&
data?.locked['ratelimitAllowList']
}
leftSection={<IconDeviceFloppy size='1rem' />}
>
Save
</Button>
</form>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsTasks({
swr: { data, isLoading },
@ -48,33 +49,41 @@ export default function ServerSettingsTasks({
<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')}
/>
<EnvTooltip envVar='TASKS_DELETE_INTERVAL' data={data} varKey='tasksDeleteInterval'>
<TextInput
label='Delete Files Interval'
description='How often to check and delete expired files.'
placeholder='30m'
{...form.getInputProps('tasksDeleteInterval')}
/>
</EnvTooltip>
<TextInput
label='Clear Invites Interval'
description='How often to check and clear expired/used invites.'
placeholder='30m'
{...form.getInputProps('tasksClearInvitesInterval')}
/>
<EnvTooltip envVar='TASKS_METRICS_INTERVAL' data={data} varKey='tasksMetricsInterval'>
<TextInput
label='Clear Invites Interval'
description='How often to check and clear expired/used invites.'
placeholder='30m'
{...form.getInputProps('tasksClearInvitesInterval')}
/>
</EnvTooltip>
<TextInput
label='Max Views Interval'
description='How often to check and delete files that have reached max views.'
placeholder='30m'
{...form.getInputProps('tasksMaxViewsInterval')}
/>
<EnvTooltip envVar='TASKS_MAX_VIEWS_INTERVAL' data={data} varKey='tasksMaxViewsInterval'>
<TextInput
label='Max Views Interval'
description='How often to check and delete files that have reached max views.'
placeholder='30m'
{...form.getInputProps('tasksMaxViewsInterval')}
/>
</EnvTooltip>
<TextInput
label='Thumbnails Interval'
description='How often to check and generate thumbnails for video files.'
placeholder='30m'
{...form.getInputProps('tasksThumbnailsInterval')}
/>
<EnvTooltip envVar='TASKS_THUMBNAILS_INTERVAL' data={data} varKey='tasksThumbnailsInterval'>
<TextInput
label='Thumbnails Interval'
description='How often to check and generate thumbnails for video files.'
placeholder='30m'
{...form.getInputProps('tasksThumbnailsInterval')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
export default function ServerSettingsUrls({
swr: { data, isLoading },
@ -38,21 +39,25 @@ export default function ServerSettingsUrls({
<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. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
<EnvTooltip envVar='URLS_ROUTE' data={data} varKey='urlsRoute'>
<TextInput
label='Route'
description='The route to use for short URLs. Requires a server restart.'
placeholder='/go'
{...form.getInputProps('urlsRoute')}
/>
</EnvTooltip>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
<EnvTooltip envVar='URLS_LENGTH' data={data} varKey='urlsLength'>
<NumberInput
label='Length'
description='The length of the short URL (for randomly generated names).'
placeholder='6'
min={1}
max={64}
{...form.getInputProps('urlsLength')}
/>
</EnvTooltip>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

View file

@ -5,6 +5,7 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
import { EnvTooltip } from '..';
const defaultExternalLinks = [
{
@ -97,98 +98,122 @@ export default function ServerSettingsWebsite({
{/* <SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'> */}
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
<EnvTooltip envVar='WEBSITE_TITLE' data={data} varKey='websiteTitle'>
<TextInput
label='Title'
description='The title of the website in browser tabs and at the top.'
placeholder='Zipline'
{...form.getInputProps('websiteTitle')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Title Logo'
description='The URL to use for the title logo. This is placed to the left of the title.'
placeholder='https://example.com/logo.png'
{...form.getInputProps('websiteTitleLogo')}
/>
<EnvTooltip envVar='WEBSITE_TITLE_LOGO' data={data} varKey='websiteTitleLogo'>
<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')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={12}>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON.'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
{...form.getInputProps('websiteExternalLinks')}
/>
<EnvTooltip envVar='WEBSITE_EXTERNAL_LINKS' data={data} varKey='websiteExternalLinks'>
<JsonInput
label='External Links'
description='The external links to show in the footer. This must be valid JSON.'
formatOnBlur
minRows={1}
maxRows={7}
autosize
placeholder={JSON.stringify(defaultExternalLinks, null, 2)}
{...form.getInputProps('websiteExternalLinks')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
<EnvTooltip envVar='WEBSITE_LOGIN_BACKGROUND' data={data} varKey='websiteLoginBackground'>
<TextInput
label='Login Background'
description='The URL to use for the login background.'
placeholder='https://example.com/background.png'
{...form.getInputProps('websiteLoginBackground')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
<EnvTooltip
envVar='WEBSITE_LOGIN_BACKGROUND_BLUR'
data={data}
varKey='websiteLoginBackgroundBlur'
>
<Switch
label='Login Background Blur'
description='Whether to blur the login background.'
{...form.getInputProps('websiteLoginBackgroundBlur', { type: 'checkbox' })}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Default Avatar'
description='The path to use for the default avatar. This must be a path to an image, not a URL.'
placeholder='/zipline/avatar.png'
{...form.getInputProps('websiteDefaultAvatar')}
/>
<EnvTooltip envVar='WEBSITE_DEFAULT_AVATAR' data={data} varKey='websiteDefaultAvatar'>
<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')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
<EnvTooltip envVar='WEBSITE_TOS' data={data} varKey='websiteTos'>
<TextInput
label='Terms of Service'
description='Path to a Markdown (.md) file to use for the terms of service.'
placeholder='/zipline/TOS.md'
{...form.getInputProps('websiteTos')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={12}>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
<EnvTooltip envVar='WEBSITE_THEME_DEFAULT' data={data} varKey='websiteThemeDefault'>
<TextInput
label='Default Theme'
description='The default theme to use for the website.'
placeholder='system'
{...form.getInputProps('websiteThemeDefault')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Dark Theme'
description='The dark theme to use for the website when the default theme is "system".'
placeholder='builtin:dark_gray'
disabled={form.values.websiteThemeDefault !== 'system'}
{...form.getInputProps('websiteThemeDark')}
/>
<EnvTooltip envVar='WEBSITE_THEME_DARK' data={data} varKey='websiteThemeDark'>
<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')}
/>
</EnvTooltip>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<TextInput
label='Light Theme'
description='The light theme to use for the website when the default theme is "system".'
placeholder='builtin:light_gray'
disabled={form.values.websiteThemeDefault !== 'system'}
{...form.getInputProps('websiteThemeLight')}
/>
<EnvTooltip envVar='WEBSITE_THEME_LIGHT' data={data} varKey='websiteThemeLight'>
<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')}
/>
</EnvTooltip>
</Grid.Col>
</Grid>

View file

@ -9,7 +9,7 @@ declare global {
}
const reloadSettings = async () => {
config = global.__config__ = validateConfigObject((await read()) as any);
config = global.__config__ = await validateConfigObject((await read()) as any);
};
config = global.__config__;

View file

@ -41,7 +41,7 @@ export const rawConfig: any = {
defaultDateFormat: undefined,
removeGpsMetadata: undefined,
randomWordsNumAdjectives: undefined,
randomWordsSeperator: undefined,
randomWordsSeparator: undefined,
},
urls: {
route: undefined,
@ -164,6 +164,113 @@ export const PROP_TO_ENV = {
'ssl.key': 'SSL_KEY',
'ssl.cert': 'SSL_CERT',
'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',
'files.randomWordsNumAdjectives': 'FILES_RANDOM_WORDS_NUM_ADJECTIVES',
'files.randomWordsSeparator': 'FILES_RANDOM_WORDS_SEPARATOR',
'urls.route': 'URLS_ROUTE',
'urls.length': 'URLS_LENGTH',
'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_NUMBER_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.loginBackgroundBlur': 'WEBSITE_LOGIN_BACKGROUND_BLUR',
'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',
'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.discord.redirectUri': 'OAUTH_DISCORD_REDIRECT_URI',
'oauth.google.clientId': 'OAUTH_GOOGLE_CLIENT_ID',
'oauth.google.clientSecret': 'OAUTH_GOOGLE_CLIENT_SECRET',
'oauth.google.redirectUri': 'OAUTH_GOOGLE_REDIRECT_URI',
'oauth.github.clientId': 'OAUTH_GITHUB_CLIENT_ID',
'oauth.github.clientSecret': 'OAUTH_GITHUB_CLIENT_SECRET',
'oauth.github.redirectUri': 'OAUTH_GITHUB_REDIRECT_URI',
'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',
'oauth.oidc.redirectUri': 'OAUTH_OIDC_REDIRECT_URI',
'mfa.totp.enabled': 'MFA_TOTP_ENABLED',
'mfa.totp.issuer': 'MFA_TOTP_ISSUER',
'mfa.passkeys': 'MFA_PASSKEYS',
'ratelimit.enabled': 'RATELIMIT_ENABLED',
'ratelimit.max': 'RATELIMIT_MAX',
'ratelimit.window': 'RATELIMIT_WINDOW',
'ratelimit.adminBypass': 'RATELIMIT_ADMIN_BYPASS',
'ratelimit.allowList': 'RATELIMIT_ALLOW_LIST',
'httpWebhook.onUpload': 'HTTPWEBHOOK_ON_UPLOAD',
'httpWebhook.onShorten': 'HTTPWEBHOOK_ON_SHORTEN',
'discord.webhookUrl': 'DISCORD_WEBHOOK_URL',
'discord.username': 'DISCORD_USERNAME',
'discord.avatarUrl': 'DISCORD_AVATAR_URL',
'discord.onUpload.webhookUrl': 'DISCORD_ON_UPLOAD_WEBHOOK_URL',
'discord.onUpload.username': 'DISCORD_ON_UPLOAD_USERNAME',
'discord.onUpload.avatarUrl': 'DISCORD_ON_UPLOAD_AVATAR_URL',
'discord.onUpload.content': 'DISCORD_ON_UPLOAD_CONTENT',
'discord.onUpload.embed': 'DISCORD_ON_UPLOAD_EMBED',
'discord.onShorten.webhookUrl': 'DISCORD_ON_SHORTEN_WEBHOOK_URL',
'discord.onShorten.username': 'DISCORD_ON_SHORTEN_USERNAME',
'discord.onShorten.avatarUrl': 'DISCORD_ON_SHORTEN_AVATAR_URL',
'discord.onShorten.content': 'DISCORD_ON_SHORTEN_CONTENT',
'discord.onShorten.embed': 'DISCORD_ON_SHORTEN_EMBED',
'pwa.enabled': 'PWA_ENABLED',
'pwa.title': 'PWA_TITLE',
'pwa.shortName': 'PWA_SHORT_NAME',
'pwa.description': 'PWA_DESCRIPTION',
'pwa.themeColor': 'PWA_THEME_COLOR',
'pwa.backgroundColor': 'PWA_BACKGROUND_COLOR',
};
export const DATABASE_TO_PROP = {
@ -191,7 +298,7 @@ export const DATABASE_TO_PROP = {
filesDefaultDateFormat: 'files.defaultDateFormat',
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
filesRandomWordsNumAdjectives: 'files.randomWordsNumAdjectives',
filesRandomWordsSeperator: 'files.randomWordsSeperator',
filesRandomWordsSeparator: 'files.randomWordsSeparator',
urlsRoute: 'urls.route',
urlsLength: 'urls.length',
@ -333,6 +440,113 @@ export function readEnv() {
env('ssl.key', 'string'),
env('ssl.cert', 'string'),
env('core.returnHttpsUrls', 'boolean'),
env('core.defaultDomain', 'string'),
env('core.tempDirectory', 'string'),
env('chunks.max', 'string'),
env('chunks.size', 'string'),
env('chunks.enabled', 'boolean'),
env('tasks.deleteInterval', 'string'),
env('tasks.clearInvitesInterval', 'string'),
env('tasks.maxViewsInterval', 'string'),
env('tasks.thumbnailsInterval', 'string'),
env('tasks.metricsInterval', 'string'),
env('files.route', 'string'),
env('files.length', 'number'),
env('files.defaultFormat', 'string'),
env('files.disabledExtensions', 'string[]'),
env('files.maxFileSize', 'string'),
env('files.defaultExpiration', 'string'),
env('files.assumeMimetypes', 'boolean'),
env('files.defaultDateFormat', 'string'),
env('files.removeGpsMetadata', 'boolean'),
env('files.randomWordsNumAdjectives', 'number'),
env('files.randomWordsSeparator', 'string'),
env('urls.route', 'string'),
env('urls.length', 'number'),
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.loginBackgroundBlur', 'boolean'),
env('website.defaultAvatar', 'string'),
env('website.tos', 'string'),
env('website.theme.default', 'string'),
env('website.theme.dark', 'string'),
env('website.theme.light', 'string'),
env('oauth.bypassLocalLogin', 'boolean'),
env('oauth.loginOnly', 'boolean'),
env('oauth.discord.clientId', 'string'),
env('oauth.discord.clientSecret', 'string'),
env('oauth.discord.redirectUri', 'string'),
env('oauth.google.clientId', 'string'),
env('oauth.google.clientSecret', 'string'),
env('oauth.google.redirectUri', 'string'),
env('oauth.github.clientId', 'string'),
env('oauth.github.clientSecret', 'string'),
env('oauth.github.redirectUri', '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('oauth.oidc.redirectUri', 'string'),
env('mfa.totp.enabled', 'boolean'),
env('mfa.totp.issuer', 'string'),
env('mfa.passkeys', 'boolean'),
env('ratelimit.enabled', 'boolean'),
env('ratelimit.max', 'number'),
env('ratelimit.window', 'number'),
env('ratelimit.adminBypass', 'boolean'),
env('ratelimit.allowList', 'string[]'),
env('httpWebhook.onUpload', 'string'),
env('httpWebhook.onShorten', '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', 'json[]'),
env('discord.onShorten.webhookUrl', 'string'),
env('discord.onShorten.username', 'string'),
env('discord.onShorten.avatarUrl', 'string'),
env('discord.onShorten.content', 'string'),
env('discord.onShorten.embed', 'json[]'),
env('pwa.enabled', 'boolean'),
env('pwa.title', 'string'),
env('pwa.shortName', 'string'),
env('pwa.description', 'string'),
env('pwa.themeColor', 'string'),
env('pwa.backgroundColor', 'string'),
];
const raw: Record<keyof typeof rawConfig, any> = {};
@ -397,6 +611,44 @@ export async function read() {
return raw;
}
export function replaceDatabaseValueWithEnv<T>(
Key: keyof typeof DATABASE_TO_PROP,
databaseValue: T,
typeString: EnvType,
): T {
const envKeys = databaseToEnv(Key);
for (let i = 0; i !== envKeys.length; ++i) {
const value = process.env[envKeys[i]];
if (value === undefined) continue;
const parsed = parse(value, typeString);
if (parsed === undefined) continue;
return parsed;
}
return databaseValue;
}
export function valueIsFromEnv(Key: keyof typeof DATABASE_TO_PROP): string | undefined {
const envKeys = databaseToEnv(Key);
for (let i = 0; i !== envKeys.length; ++i) {
const value = process.env[envKeys[i]];
if (value !== undefined) return value;
}
return undefined;
}
export function databaseToEnv(key: keyof typeof DATABASE_TO_PROP): string[] {
const prop = PROP_TO_ENV[DATABASE_TO_PROP[key] as keyof typeof PROP_TO_ENV];
if (!prop) return [];
if (typeof prop === 'string') return [prop];
return prop;
}
function isObject(value: any) {
return typeof value === 'object' && value !== null;
}

View file

@ -3,6 +3,7 @@ import { join, resolve } from 'path';
import { type ZodIssue, z } from 'zod';
import { log } from '../logger';
import { PROP_TO_ENV, ParsedConfig } from './read';
import { parseSettings } from '@/server/routes/api/server/settings';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
@ -95,7 +96,7 @@ export const schema = z.object({
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'),
removeGpsMetadata: z.boolean().default(false),
randomWordsNumAdjectives: z.number().default(3),
randomWordsSeperator: z.string().default('-'),
randomWordsSeparator: z.string().default('-'),
}),
urls: z.object({
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
@ -323,7 +324,7 @@ export type Config = z.infer<typeof schema>;
const logger = log('config').c('validate');
export function validateConfigObject(env: ParsedConfig): Config {
export async function validateConfigObject(env: ParsedConfig): Promise<Config> {
const building = !!process.env.ZIPLINE_BUILD;
if (building) {
@ -343,6 +344,113 @@ export function validateConfigObject(env: ParsedConfig): Config {
process.exit(1);
}
// flatten object for use with settingsValidator
const flat = {
coreReturnHttpsUrls: validated.data.core.returnHttpsUrls,
corePort: validated.data.core.port,
coreHostname: validated.data.core.hostname,
coreSecret: validated.data.core.secret,
coreDatabaseUrl: validated.data.core.databaseUrl,
coreDefaultDomain: validated.data.core.defaultDomain,
coreTempDirectory: validated.data.core.tempDirectory,
chunksMax: validated.data.chunks.max,
chunksSize: validated.data.chunks.size,
chunksEnabled: validated.data.chunks.enabled,
tasksDeleteInterval: validated.data.tasks.deleteInterval,
tasksClearInvitesInterval: validated.data.tasks.clearInvitesInterval,
tasksMaxViewsInterval: validated.data.tasks.maxViewsInterval,
tasksThumbnailsInterval: validated.data.tasks.thumbnailsInterval,
tasksMetricsInterval: validated.data.tasks.metricsInterval,
filesRoute: validated.data.files.route,
filesLength: validated.data.files.length,
filesDefaultFormat: validated.data.files.defaultFormat,
filesDisabledExtensions: validated.data.files.disabledExtensions,
filesMaxFileSize: validated.data.files.maxFileSize,
filesDefaultExpiration: validated.data.files.defaultExpiration,
filesAssumeMimetypes: validated.data.files.assumeMimetypes,
filesDefaultDateFormat: validated.data.files.defaultDateFormat,
filesRemoveGpsMetadata: validated.data.files.removeGpsMetadata,
filesRandomWordsNumAdjectives: validated.data.files.randomWordsNumAdjectives,
filesRandomWordsSeparator: validated.data.files.randomWordsSeparator,
urlsRoute: validated.data.urls.route,
urlsLength: validated.data.urls.length,
datasourceType: validated.data.datasource.type,
datasourceS3AccessKeyId: validated.data.datasource.s3?.accessKeyId,
datasourceS3SecretAccessKey: validated.data.datasource.s3?.secretAccessKey,
datasourceS3Region: validated.data.datasource.s3?.region,
datasourceS3Bucket: validated.data.datasource.s3?.bucket,
datasourceS3Endpoint: validated.data.datasource.s3?.endpoint,
datasourceS3ForcePathStyle: validated.data.datasource.s3?.forcePathStyle,
datasourceLocalDirectory: validated.data.datasource.local.directory,
featuresImageCompression: validated.data.features.imageCompression,
featuresRobotsTxt: validated.data.features.robotsTxt,
featuresHealthcheck: validated.data.features.healthcheck,
featuresUserRegistration: validated.data.features.userRegistration,
featuresOauthRegistration: validated.data.features.oauthRegistration,
featuresDeleteOnMaxViews: validated.data.features.deleteOnMaxViews,
featuresThumbnailsEnabled: validated.data.features.thumbnails.enabled,
featuresThumbnailsNumThreads: validated.data.features.thumbnails.num_threads,
featuresMetricsEnabled: validated.data.features.metrics.enabled,
featuresMetricsAdminOnly: validated.data.features.metrics.adminOnly,
featuresMetricsShowUserSpecific: validated.data.features.metrics.showUserSpecific,
invitesEnabled: validated.data.invites.enabled,
invitesLength: validated.data.invites.length,
websiteTitle: validated.data.website.title,
websiteTitleLogo: validated.data.website.titleLogo,
websiteExternalLinks: validated.data.website.externalLinks,
websiteLoginBackground: validated.data.website.loginBackground,
websiteLoginBackgroundBlur: validated.data.website.loginBackgroundBlur,
websiteDefaultAvatar: validated.data.website.defaultAvatar,
websiteThemeDefault: validated.data.website.theme.default,
websiteThemeDark: validated.data.website.theme.dark,
websiteThemeLight: validated.data.website.theme.light,
websiteTos: validated.data.website.tos,
mfaTotpEnabled: validated.data.mfa.totp.enabled,
mfaTotpIssuer: validated.data.mfa.totp.issuer,
mfaPasskeys: validated.data.mfa.passkeys,
oauthBypassLocalLogin: validated.data.oauth.bypassLocalLogin,
oauthLoginOnly: validated.data.oauth.loginOnly,
oauthDiscordClientId: validated.data.oauth.discord.clientId,
oauthDiscordClientSecret: validated.data.oauth.discord.clientSecret,
oauthDiscordRedirectUri: validated.data.oauth.discord.redirectUri,
oauthGithubClientId: validated.data.oauth.github.clientId,
oauthGithubClientSecret: validated.data.oauth.github.clientSecret,
oauthGithubRedirectUri: validated.data.oauth.github.redirectUri,
oauthGoogleClientId: validated.data.oauth.google.clientId,
oauthGoogleClientSecret: validated.data.oauth.google.clientSecret,
oauthGoogleRedirectUri: validated.data.oauth.google.redirectUri,
oauthOidcClientId: validated.data.oauth.oidc.clientId,
oauthOidcClientSecret: validated.data.oauth.oidc.clientSecret,
oauthOidcAuthorizeUrl: validated.data.oauth.oidc.authorizeUrl,
oauthOidcUserinfoUrl: validated.data.oauth.oidc.userinfoUrl,
oauthOidcTokenUrl: validated.data.oauth.oidc.tokenUrl,
oauthOidcRedirectUri: validated.data.oauth.oidc.redirectUri,
discordWebhookUrl: validated.data.discord?.webhookUrl,
discordUsername: validated.data.discord?.username,
discordAvatarUrl: validated.data.discord?.avatarUrl,
discordOnUpload: validated.data.discord?.onUpload,
discordOnShorten: validated.data.discord?.onShorten,
ratelimitEnabled: validated.data.ratelimit.enabled,
ratelimitMax: validated.data.ratelimit.max,
ratelimitWindow: validated.data.ratelimit.window,
ratelimitAdminBypass: validated.data.ratelimit.adminBypass,
ratelimitAllowList: validated.data.ratelimit.allowList,
httpWebhookOnUpload: validated.data.httpWebhook.onUpload,
httpWebhookOnShorten: validated.data.httpWebhook.onShorten,
};
const result = await parseSettings(flat);
if (!result.success) {
logger.error('There was an error while validating the environment.');
for (const error of result.error?.issues ?? []) {
handleError(error);
}
process.exit(1);
}
logger.debug('reloaded config');
return validated.data;

View file

@ -3,6 +3,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
import { userViewSchema } from './models/user';
import { metricDataSchema } from './models/metric';
import { metadataSchema } from './models/incompleteFile';
import { SettingsExtension } from './settingsExtension';
const building = !!process.env.ZIPLINE_BUILD;
@ -38,42 +39,44 @@ function getClient() {
const client = new PrismaClient({
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,
}).$extends({
result: {
file: {
size: {
needs: { size: true },
compute({ size }: { size: bigint }) {
return Number(size);
})
.$extends({
result: {
file: {
size: {
needs: { size: true },
compute({ size }: { size: bigint }) {
return Number(size);
},
},
},
user: {
view: {
needs: { view: true },
compute({ view }: { view: Prisma.JsonValue }) {
return userViewSchema.parse(view);
},
},
},
metric: {
data: {
needs: { data: true },
compute({ data }: { data: Prisma.JsonValue }) {
return metricDataSchema.parse(data);
},
},
},
incompleteFile: {
metadata: {
needs: { metadata: true },
compute({ metadata }: { metadata: Prisma.JsonValue }) {
return metadataSchema.parse(metadata);
},
},
},
},
user: {
view: {
needs: { view: true },
compute({ view }: { view: Prisma.JsonValue }) {
return userViewSchema.parse(view);
},
},
},
metric: {
data: {
needs: { data: true },
compute({ data }: { data: Prisma.JsonValue }) {
return metricDataSchema.parse(data);
},
},
},
incompleteFile: {
metadata: {
needs: { metadata: true },
compute({ metadata }: { metadata: Prisma.JsonValue }) {
return metadataSchema.parse(metadata);
},
},
},
},
});
})
.$extends(SettingsExtension);
client.$connect();
return client;

View file

@ -1,7 +1,7 @@
import { prisma } from '..';
export async function getZipline() {
const zipline = await prisma.zipline.findFirst();
const zipline = await prisma.zipline.getSettings();
if (!zipline) {
return prisma.zipline.create({
data: {

View file

@ -0,0 +1,401 @@
import { Prisma } from '@prisma/client';
import { prisma } from '.';
import { replaceDatabaseValueWithEnv } from '../config/read';
export const SettingsExtension = Prisma.defineExtension({
name: 'settings',
model: {
zipline: {
async getSettings(...args: any[]) {
let settings = await prisma.zipline.findFirst(...args);
if (!settings) {
return settings;
}
// I was going to do a loop, but i kept getting typescript errors. please forgive me
settings = {
id: settings.id,
createdAt: settings.createdAt,
updatedAt: settings.updatedAt,
firstSetup: settings.firstSetup,
coreReturnHttpsUrls: replaceDatabaseValueWithEnv(
'coreReturnHttpsUrls',
settings.coreReturnHttpsUrls,
'boolean',
),
coreDefaultDomain: replaceDatabaseValueWithEnv(
'coreDefaultDomain',
settings.coreDefaultDomain,
'string',
),
coreTempDirectory: replaceDatabaseValueWithEnv(
'coreTempDirectory',
settings.coreTempDirectory,
'string',
),
chunksMax: replaceDatabaseValueWithEnv('chunksMax', settings.chunksMax, 'string'),
chunksSize: replaceDatabaseValueWithEnv('chunksSize', settings.chunksSize, 'string'),
chunksEnabled: replaceDatabaseValueWithEnv('chunksEnabled', settings.chunksEnabled, 'boolean'),
tasksDeleteInterval: replaceDatabaseValueWithEnv(
'tasksDeleteInterval',
settings.tasksDeleteInterval,
'ms',
),
tasksClearInvitesInterval: replaceDatabaseValueWithEnv(
'tasksClearInvitesInterval',
settings.tasksClearInvitesInterval,
'ms',
),
tasksMaxViewsInterval: replaceDatabaseValueWithEnv(
'tasksMaxViewsInterval',
settings.tasksMaxViewsInterval,
'ms',
),
tasksThumbnailsInterval: replaceDatabaseValueWithEnv(
'tasksThumbnailsInterval',
settings.tasksThumbnailsInterval,
'ms',
),
tasksMetricsInterval: replaceDatabaseValueWithEnv(
'tasksMetricsInterval',
settings.tasksMetricsInterval,
'ms',
),
filesRoute: replaceDatabaseValueWithEnv('filesRoute', settings.filesRoute, 'string'),
filesLength: replaceDatabaseValueWithEnv('filesLength', settings.filesLength, 'number'),
filesDefaultFormat: replaceDatabaseValueWithEnv(
'filesDefaultFormat',
settings.filesDefaultFormat,
'string',
),
filesDisabledExtensions: replaceDatabaseValueWithEnv(
'filesDisabledExtensions',
settings.filesDisabledExtensions,
'string[]',
),
filesMaxFileSize: replaceDatabaseValueWithEnv(
'filesMaxFileSize',
settings.filesMaxFileSize,
'byte',
),
filesDefaultExpiration: replaceDatabaseValueWithEnv(
'filesDefaultExpiration',
settings.filesDefaultExpiration,
'string',
),
filesAssumeMimetypes: replaceDatabaseValueWithEnv(
'filesAssumeMimetypes',
settings.filesAssumeMimetypes,
'boolean',
),
filesDefaultDateFormat: replaceDatabaseValueWithEnv(
'filesDefaultDateFormat',
settings.filesDefaultDateFormat,
'string',
),
filesRemoveGpsMetadata: replaceDatabaseValueWithEnv(
'filesRemoveGpsMetadata',
settings.filesRemoveGpsMetadata,
'boolean',
),
filesRandomWordsNumAdjectives: replaceDatabaseValueWithEnv(
'filesRandomWordsNumAdjectives',
settings.filesRandomWordsNumAdjectives,
'number',
),
filesRandomWordsSeparator: replaceDatabaseValueWithEnv(
'filesRandomWordsSeparator',
settings.filesRandomWordsSeparator,
'string',
),
urlsRoute: replaceDatabaseValueWithEnv('urlsRoute', settings.urlsRoute, 'string'),
urlsLength: replaceDatabaseValueWithEnv('urlsLength', settings.urlsLength, 'number'),
featuresImageCompression: replaceDatabaseValueWithEnv(
'featuresImageCompression',
settings.featuresImageCompression,
'boolean',
),
featuresRobotsTxt: replaceDatabaseValueWithEnv(
'featuresRobotsTxt',
settings.featuresRobotsTxt,
'boolean',
),
featuresHealthcheck: replaceDatabaseValueWithEnv(
'featuresHealthcheck',
settings.featuresHealthcheck,
'boolean',
),
featuresUserRegistration: replaceDatabaseValueWithEnv(
'featuresUserRegistration',
settings.featuresUserRegistration,
'boolean',
),
featuresOauthRegistration: replaceDatabaseValueWithEnv(
'featuresOauthRegistration',
settings.featuresOauthRegistration,
'boolean',
),
featuresDeleteOnMaxViews: replaceDatabaseValueWithEnv(
'featuresDeleteOnMaxViews',
settings.featuresDeleteOnMaxViews,
'boolean',
),
featuresThumbnailsEnabled: replaceDatabaseValueWithEnv(
'featuresThumbnailsEnabled',
settings.featuresThumbnailsEnabled,
'boolean',
),
featuresThumbnailsNumberThreads: replaceDatabaseValueWithEnv(
'featuresThumbnailsNumberThreads',
settings.featuresThumbnailsNumberThreads,
'number',
),
featuresMetricsEnabled: replaceDatabaseValueWithEnv(
'featuresMetricsEnabled',
settings.featuresMetricsEnabled,
'boolean',
),
featuresMetricsAdminOnly: replaceDatabaseValueWithEnv(
'featuresMetricsAdminOnly',
settings.featuresMetricsAdminOnly,
'boolean',
),
featuresMetricsShowUserSpecific: replaceDatabaseValueWithEnv(
'featuresMetricsShowUserSpecific',
settings.featuresMetricsShowUserSpecific,
'boolean',
),
invitesEnabled: replaceDatabaseValueWithEnv('invitesEnabled', settings.invitesEnabled, 'boolean'),
invitesLength: replaceDatabaseValueWithEnv('invitesLength', settings.invitesLength, 'number'),
websiteTitle: replaceDatabaseValueWithEnv('websiteTitle', settings.websiteTitle, 'string'),
websiteTitleLogo: replaceDatabaseValueWithEnv(
'websiteTitleLogo',
settings.websiteTitleLogo,
'string',
),
websiteExternalLinks: replaceDatabaseValueWithEnv(
'websiteExternalLinks',
settings.websiteExternalLinks,
'json[]',
),
websiteLoginBackground: replaceDatabaseValueWithEnv(
'websiteLoginBackground',
settings.websiteLoginBackground,
'string',
),
websiteLoginBackgroundBlur: replaceDatabaseValueWithEnv(
'websiteLoginBackgroundBlur',
settings.websiteLoginBackgroundBlur,
'boolean',
),
websiteDefaultAvatar: replaceDatabaseValueWithEnv(
'websiteDefaultAvatar',
settings.websiteDefaultAvatar,
'string',
),
websiteTos: replaceDatabaseValueWithEnv('websiteTos', settings.websiteTos, 'string'),
websiteThemeDefault: replaceDatabaseValueWithEnv(
'websiteThemeDefault',
settings.websiteThemeDefault,
'string',
),
websiteThemeDark: replaceDatabaseValueWithEnv(
'websiteThemeDark',
settings.websiteThemeDark,
'string',
),
websiteThemeLight: replaceDatabaseValueWithEnv(
'websiteThemeLight',
settings.websiteThemeLight,
'string',
),
oauthBypassLocalLogin: replaceDatabaseValueWithEnv(
'oauthBypassLocalLogin',
settings.oauthBypassLocalLogin,
'boolean',
),
oauthLoginOnly: replaceDatabaseValueWithEnv('oauthLoginOnly', settings.oauthLoginOnly, 'boolean'),
oauthDiscordClientId: replaceDatabaseValueWithEnv(
'oauthDiscordClientId',
settings.oauthDiscordClientId,
'string',
),
oauthDiscordClientSecret: replaceDatabaseValueWithEnv(
'oauthDiscordClientSecret',
settings.oauthDiscordClientSecret,
'string',
),
oauthDiscordRedirectUri: replaceDatabaseValueWithEnv(
'oauthDiscordRedirectUri',
settings.oauthDiscordRedirectUri,
'string',
),
oauthGoogleClientId: replaceDatabaseValueWithEnv(
'oauthGoogleClientId',
settings.oauthGoogleClientId,
'string',
),
oauthGoogleClientSecret: replaceDatabaseValueWithEnv(
'oauthGoogleClientSecret',
settings.oauthGoogleClientSecret,
'string',
),
oauthGoogleRedirectUri: replaceDatabaseValueWithEnv(
'oauthGoogleRedirectUri',
settings.oauthGoogleRedirectUri,
'string',
),
oauthGithubClientId: replaceDatabaseValueWithEnv(
'oauthGithubClientId',
settings.oauthGithubClientId,
'string',
),
oauthGithubClientSecret: replaceDatabaseValueWithEnv(
'oauthGithubClientSecret',
settings.oauthGithubClientSecret,
'string',
),
oauthGithubRedirectUri: replaceDatabaseValueWithEnv(
'oauthGithubRedirectUri',
settings.oauthGithubRedirectUri,
'string',
),
oauthOidcClientId: replaceDatabaseValueWithEnv(
'oauthOidcClientId',
settings.oauthOidcClientId,
'string',
),
oauthOidcClientSecret: replaceDatabaseValueWithEnv(
'oauthOidcClientSecret',
settings.oauthOidcClientSecret,
'string',
),
oauthOidcAuthorizeUrl: replaceDatabaseValueWithEnv(
'oauthOidcAuthorizeUrl',
settings.oauthOidcAuthorizeUrl,
'string',
),
oauthOidcUserinfoUrl: replaceDatabaseValueWithEnv(
'oauthOidcUserinfoUrl',
settings.oauthOidcUserinfoUrl,
'string',
),
oauthOidcTokenUrl: replaceDatabaseValueWithEnv(
'oauthOidcTokenUrl',
settings.oauthOidcTokenUrl,
'string',
),
oauthOidcRedirectUri: replaceDatabaseValueWithEnv(
'oauthOidcRedirectUri',
settings.oauthOidcRedirectUri,
'string',
),
mfaTotpEnabled: replaceDatabaseValueWithEnv('mfaTotpEnabled', settings.mfaTotpEnabled, 'boolean'),
mfaTotpIssuer: replaceDatabaseValueWithEnv('mfaTotpIssuer', settings.mfaTotpIssuer, 'string'),
mfaPasskeys: replaceDatabaseValueWithEnv('mfaPasskeys', settings.mfaPasskeys, 'boolean'),
ratelimitEnabled: replaceDatabaseValueWithEnv(
'ratelimitEnabled',
settings.ratelimitEnabled,
'boolean',
),
ratelimitMax: replaceDatabaseValueWithEnv('ratelimitMax', settings.ratelimitMax, 'number'),
ratelimitWindow: replaceDatabaseValueWithEnv('ratelimitWindow', settings.ratelimitWindow, 'number'),
ratelimitAdminBypass: replaceDatabaseValueWithEnv(
'ratelimitAdminBypass',
settings.ratelimitAdminBypass,
'boolean',
),
ratelimitAllowList: replaceDatabaseValueWithEnv(
'ratelimitAllowList',
settings.ratelimitAllowList,
'string[]',
),
httpWebhookOnUpload: replaceDatabaseValueWithEnv(
'httpWebhookOnUpload',
settings.httpWebhookOnUpload,
'string',
),
httpWebhookOnShorten: replaceDatabaseValueWithEnv(
'httpWebhookOnShorten',
settings.httpWebhookOnShorten,
'string',
),
discordWebhookUrl: replaceDatabaseValueWithEnv(
'discordWebhookUrl',
settings.discordWebhookUrl,
'string',
),
discordUsername: replaceDatabaseValueWithEnv('discordUsername', settings.discordUsername, 'string'),
discordAvatarUrl: replaceDatabaseValueWithEnv(
'discordAvatarUrl',
settings.discordAvatarUrl,
'string',
),
discordOnUploadWebhookUrl: replaceDatabaseValueWithEnv(
'discordOnUploadWebhookUrl',
settings.discordOnUploadWebhookUrl,
'string',
),
discordOnUploadUsername: replaceDatabaseValueWithEnv(
'discordOnUploadUsername',
settings.discordOnUploadUsername,
'string',
),
discordOnUploadAvatarUrl: replaceDatabaseValueWithEnv(
'discordOnUploadAvatarUrl',
settings.discordOnUploadAvatarUrl,
'string',
),
discordOnUploadContent: replaceDatabaseValueWithEnv(
'discordOnUploadContent',
settings.discordOnUploadContent,
'string',
),
discordOnUploadEmbed: replaceDatabaseValueWithEnv(
'discordOnUploadEmbed',
settings.discordOnUploadEmbed,
'json[]',
),
discordOnShortenWebhookUrl: replaceDatabaseValueWithEnv(
'discordOnShortenWebhookUrl',
settings.discordOnShortenWebhookUrl,
'string',
),
discordOnShortenUsername: replaceDatabaseValueWithEnv(
'discordOnShortenUsername',
settings.discordOnShortenUsername,
'string',
),
discordOnShortenAvatarUrl: replaceDatabaseValueWithEnv(
'discordOnShortenAvatarUrl',
settings.discordOnShortenAvatarUrl,
'string',
),
discordOnShortenContent: replaceDatabaseValueWithEnv(
'discordOnShortenContent',
settings.discordOnShortenContent,
'string',
),
discordOnShortenEmbed: replaceDatabaseValueWithEnv(
'discordOnShortenEmbed',
settings.discordOnShortenEmbed,
'json[]',
),
pwaEnabled: replaceDatabaseValueWithEnv('pwaEnabled', settings.pwaEnabled, 'boolean'),
pwaTitle: replaceDatabaseValueWithEnv('pwaTitle', settings.pwaTitle, 'string'),
pwaShortName: replaceDatabaseValueWithEnv('pwaShortName', settings.pwaShortName, 'string'),
pwaDescription: replaceDatabaseValueWithEnv('pwaDescription', settings.pwaDescription, 'string'),
pwaThemeColor: replaceDatabaseValueWithEnv('pwaThemeColor', settings.pwaThemeColor, 'string'),
pwaBackgroundColor: replaceDatabaseValueWithEnv(
'pwaBackgroundColor',
settings.pwaBackgroundColor,
'string',
),
};
return settings;
},
async getSettingsRaw(...args: any[]) {
return await prisma.zipline.findFirst(...args);
},
},
},
});

View file

@ -20,7 +20,7 @@ export function formatFileName(nameFormat: Config['files']['defaultFormat'], ori
return name;
case 'random-words':
case 'gfycat':
return randomWords(config.files.randomWordsNumAdjectives, config.files.randomWordsSeperator);
return randomWords(config.files.randomWordsNumAdjectives, config.files.randomWordsSeparator);
default:
return randomCharacters(config.files.length);
}

View file

@ -28,13 +28,13 @@ function importWords(): {
}
}
export function randomWords(numAdjectives: number = 2, seperator: string = '-') {
export function randomWords(numAdjectives: number = 2, separator: string = '-') {
const { adjectives, animals } = importWords();
let words = '';
for (let i = 0; i !== numAdjectives; ++i) {
words += adjectives[randomIndex(adjectives.length)] + seperator;
words += adjectives[randomIndex(adjectives.length)] + separator;
}
words += animals[randomIndex(animals.length)];

View file

@ -1,6 +1,6 @@
import { bytes } from '@/lib/bytes';
import { reloadSettings } from '@/lib/config';
import { readDatabaseSettings } from '@/lib/config/read';
import { DATABASE_TO_PROP, readDatabaseSettings, valueIsFromEnv as valueFromEnv } from '@/lib/config/read';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { readThemes } from '@/lib/theme/file';
@ -15,7 +15,11 @@ import { z } from 'zod';
type Settings = Awaited<ReturnType<typeof readDatabaseSettings>>;
export type ApiServerSettingsResponse = Settings;
type LockedSettings = {
locked: any;
};
export type ApiServerSettingsResponse = Settings & LockedSettings;
type Body = Partial<Settings>;
const reservedRoutes = ['/dashboard', '/api', '/raw', '/robots.txt', '/manifest.json', '/favicon.ico'];
@ -60,7 +64,7 @@ export default fastifyPlugin(
preHandler: [userMiddleware, administratorMiddleware],
},
async (_, res) => {
const settings = await prisma.zipline.findFirst({
const settings = await prisma.zipline.getSettingsRaw({
omit: {
createdAt: true,
updatedAt: true,
@ -70,8 +74,20 @@ export default fastifyPlugin(
});
if (!settings) return res.notFound('no settings table found');
const settingsResponse: ApiServerSettingsResponse = {
...settings,
locked: {},
};
return res.send(settings);
for (const key in DATABASE_TO_PROP) {
const val = valueFromEnv(key as keyof typeof DATABASE_TO_PROP);
if (val === undefined) {
continue;
}
settingsResponse.locked[key] = val;
}
return res.send(settingsResponse);
},
);
@ -81,253 +97,11 @@ export default fastifyPlugin(
preHandler: [userMiddleware, administratorMiddleware],
},
async (req, res) => {
const settings = await prisma.zipline.findFirst();
const settings = await prisma.zipline.getSettingsRaw();
if (!settings) return res.notFound('no settings table found');
const themes = (await readThemes()).map((x) => x.id);
const result: any = await parseSettings(req.body);
const settingsBodySchema = z
.object({
coreTempDirectory: z.string().refine((dir) => {
try {
return !dir || statSync(dir).isDirectory();
} catch {
return false;
}
}, 'Directory does not exist'),
coreDefaultDomain: z
.string()
.nullable()
.refine((value) => !value || /^[a-z0-9-.]+$/.test(value), 'Invalid domain format'),
coreReturnHttpsUrls: z.boolean(),
chunksEnabled: z.boolean(),
chunksMax: zBytes,
chunksSize: zBytes,
tasksDeleteInterval: zMs,
tasksClearInvitesInterval: zMs,
tasksMaxViewsInterval: zMs,
tasksThumbnailsInterval: zMs,
tasksMetricsInterval: zMs,
filesRoute: z
.string()
.startsWith('/')
.refine(
(value) => !reservedRoutes.some((route) => value.startsWith(route)),
'Provided route is reserved',
),
filesLength: z.number().min(1).max(64),
filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']),
filesDisabledExtensions: z
.union([
z.array(z.string().refine((s) => !s.startsWith('.'), 'extension can\'t include "."')),
z.string(),
])
.transform((value) =>
typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value,
),
filesMaxFileSize: zBytes,
filesDefaultExpiration: zMs.nullable(),
filesAssumeMimetypes: z.boolean(),
filesDefaultDateFormat: z.string(),
filesRemoveGpsMetadata: z.boolean(),
filesRandomWordsNumAdjectives: z.number().min(1).max(20),
filesRandomWordsSeparator: z.string(),
urlsRoute: z
.string()
.startsWith('/')
.refine(
(value) => !reservedRoutes.some((route) => value.startsWith(route)),
'Provided route is reserved',
),
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,
'Number of threads must be less than or equal to the number of CPUs: ' + cpus().length,
),
featuresMetricsEnabled: z.boolean(),
featuresMetricsAdminOnly: z.boolean(),
featuresMetricsShowUserSpecific: 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(),
websiteLoginBackgroundBlur: z.boolean(),
websiteDefaultAvatar: z
.string()
.nullable()
.transform((s) => (s ? resolve(s) : null))
.refine((input) => {
try {
return !input || statSync(input).isFile();
} catch {
return false;
}
}, 'File does not exist'),
websiteTos: z
.string()
.nullable()
.refine((input) => !input || input.endsWith('.md'), 'File is not a markdown file')
.refine((input) => {
try {
return !input || statSync(input).isFile();
} catch {
return false;
}
}, 'File does not exist'),
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(),
oauthDiscordRedirectUri: z.string().url().endsWith('/api/auth/oauth/discord').nullable(),
oauthGoogleClientId: z.string().nullable(),
oauthGoogleClientSecret: z.string().nullable(),
oauthGoogleRedirectUri: z.string().url().endsWith('/api/auth/oauth/google').nullable(),
oauthGithubClientId: z.string().nullable(),
oauthGithubClientSecret: z.string().nullable(),
oauthGithubRedirectUri: z.string().url().endsWith('/api/auth/oauth/github').nullable(),
oauthOidcClientId: z.string().nullable(),
oauthOidcClientSecret: z.string().nullable(),
oauthOidcAuthorizeUrl: z.string().url().nullable(),
oauthOidcTokenUrl: z.string().url().nullable(),
oauthOidcUserinfoUrl: z.string().url().nullable(),
oauthOidcRedirectUri: z.string().url().endsWith('/api/auth/oauth/oidc').nullable(),
mfaTotpEnabled: z.boolean(),
mfaTotpIssuer: z.string(),
mfaPasskeys: z.boolean(),
ratelimitEnabled: z.boolean(),
ratelimitMax: z.number().refine((value) => value > 0, 'Value must be greater than 0'),
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,
pwaEnabled: z.boolean(),
pwaTitle: z.string(),
pwaShortName: z.string(),
pwaDescription: z.string(),
pwaThemeColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})$/),
pwaBackgroundColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})/),
})
.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',
],
},
)
.refine((data) => !data.ratelimitWindow || (data.ratelimitMax && data.ratelimitMax > 0), {
message: 'ratelimitMax must be set if ratelimitWindow is set',
path: ['ratelimitMax'],
});
const result = settingsBodySchema.safeParse(req.body);
if (!result.success) {
logger.warn('invalid settings update', {
issues: result.error.issues,
@ -362,7 +136,22 @@ export default fastifyPlugin(
by: req.user.username,
});
return res.send(newSettings);
const settingsResponse: ApiServerSettingsResponse = {
...newSettings,
locked: {},
};
for (const key in DATABASE_TO_PROP) {
if (DATABASE_TO_PROP.hasOwnProperty(key)) {
const val = valueFromEnv(key as keyof typeof DATABASE_TO_PROP);
if (val === undefined) {
continue;
}
settingsResponse.locked[key] = val;
}
}
return res.send(settingsResponse);
},
);
@ -370,3 +159,251 @@ export default fastifyPlugin(
},
{ name: PATH },
);
export async function parseSettings(body: object) {
const themes = (await readThemes()).map((x) => x.id);
const settingsBodySchema = z
.object({
coreTempDirectory: z.string().refine((dir) => {
try {
return !dir || statSync(dir).isDirectory();
} catch {
return false;
}
}, 'Directory does not exist'),
coreDefaultDomain: z
.string()
.nullable()
.refine((value) => !value || /^[a-z0-9-.]+$/.test(value), 'Invalid domain format'),
coreReturnHttpsUrls: z.boolean(),
chunksEnabled: z.boolean(),
chunksMax: zBytes,
chunksSize: zBytes,
tasksDeleteInterval: zMs,
tasksClearInvitesInterval: zMs,
tasksMaxViewsInterval: zMs,
tasksThumbnailsInterval: zMs,
tasksMetricsInterval: zMs,
filesRoute: z
.string()
.startsWith('/')
.refine(
(value) => !reservedRoutes.some((route) => value.startsWith(route)),
'Provided route is reserved',
),
filesLength: z.number().min(1).max(64),
filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']),
filesDisabledExtensions: z
.union([
z.array(z.string().refine((s) => !s.startsWith('.'), 'extension can\'t include "."')),
z.string(),
])
.transform((value) =>
typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value,
),
filesMaxFileSize: zBytes,
filesDefaultExpiration: zMs.nullable(),
filesAssumeMimetypes: z.boolean(),
filesDefaultDateFormat: z.string(),
filesRemoveGpsMetadata: z.boolean(),
filesRandomWordsNumAdjectives: z.number().min(1).max(20),
filesRandomWordsSeparator: z.string(),
urlsRoute: z
.string()
.startsWith('/')
.refine(
(value) => !reservedRoutes.some((route) => value.startsWith(route)),
'Provided route is reserved',
),
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,
'Number of threads must be less than or equal to the number of CPUs: ' + cpus().length,
),
featuresMetricsEnabled: z.boolean(),
featuresMetricsAdminOnly: z.boolean(),
featuresMetricsShowUserSpecific: 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(),
websiteLoginBackgroundBlur: z.boolean(),
websiteDefaultAvatar: z
.string()
.nullable()
.transform((s) => (s ? resolve(s) : null))
.refine((input) => {
try {
return !input || statSync(input).isFile();
} catch {
return false;
}
}, 'File does not exist'),
websiteTos: z
.string()
.nullable()
.refine((input) => !input || input.endsWith('.md'), 'File is not a markdown file')
.refine((input) => {
try {
return !input || statSync(input).isFile();
} catch {
return false;
}
}, 'File does not exist'),
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(),
oauthDiscordRedirectUri: z.string().url().endsWith('/api/auth/oauth/discord').nullable(),
oauthGoogleClientId: z.string().nullable(),
oauthGoogleClientSecret: z.string().nullable(),
oauthGoogleRedirectUri: z.string().url().endsWith('/api/auth/oauth/google').nullable(),
oauthGithubClientId: z.string().nullable(),
oauthGithubClientSecret: z.string().nullable(),
oauthGithubRedirectUri: z.string().url().endsWith('/api/auth/oauth/github').nullable(),
oauthOidcClientId: z.string().nullable(),
oauthOidcClientSecret: z.string().nullable(),
oauthOidcAuthorizeUrl: z.string().url().nullable(),
oauthOidcTokenUrl: z.string().url().nullable(),
oauthOidcUserinfoUrl: z.string().url().nullable(),
oauthOidcRedirectUri: z.string().url().endsWith('/api/auth/oauth/oidc').nullable(),
mfaTotpEnabled: z.boolean(),
mfaTotpIssuer: z.string(),
mfaPasskeys: z.boolean(),
ratelimitEnabled: z.boolean(),
ratelimitMax: z.number().refine((value) => value > 0, 'Value must be greater than 0'),
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,
pwaEnabled: z.boolean(),
pwaTitle: z.string(),
pwaShortName: z.string(),
pwaDescription: z.string(),
pwaThemeColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})$/),
pwaBackgroundColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})/),
})
.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',
],
},
)
.refine((data) => !data.ratelimitWindow || (data.ratelimitMax && data.ratelimitMax > 0), {
message: 'ratelimitMax must be set if ratelimitWindow is set',
path: ['ratelimitMax'],
});
const result = settingsBodySchema.safeParse(body);
return result;
}