feat: predefined domains (#822)

* feat(domains): add domains to server settings

* fix(domains): fix linting errors

* fix(domains): remove unused imports

* fix(urls): fix typo

* feat(domains): remove expiration date from domains

* feat(domains): changed domains from JSONB to TEXT[]

* fix(domains): linter errors

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
This commit is contained in:
curet
2025-07-02 19:52:33 +02:00
committed by GitHub
parent 4652ada85e
commit 38a90787d0
9 changed files with 154 additions and 16 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -135,6 +135,8 @@ model Zipline {
pwaDescription String @default("Zipline")
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
}
model User {

View File

@@ -3,6 +3,7 @@ import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } fr
import useSWR from 'swr';
import dynamic from 'next/dynamic';
import { useDisclosure } from '@mantine/hooks';
import Domains from './parts/Domains';
function SettingsSkeleton() {
return <Skeleton height={280} animate />;
@@ -105,6 +106,8 @@ export default function DashboardSettings() {
<PWA swr={{ data, isLoading }} />
<HttpWebhook swr={{ data, isLoading }} />
<Domains swr={{ data, isLoading }} />
</>
)}
</SimpleGrid>

View File

@@ -0,0 +1,94 @@
import { Response } from '@/lib/api/response';
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function Domains({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const [domains, setDomains] = useState<string[]>([]);
const form = useForm({
initialValues: {
newDomain: '',
},
});
const onSubmit = settingsOnSubmit(router, form);
useEffect(() => {
if (!data) return;
const domainsData = Array.isArray(data.settings.domains)
? data.settings.domains.map((d) => String(d))
: [];
setDomains(domainsData);
}, [data]);
const addDomain = () => {
const { newDomain } = form.values;
if (!newDomain) return;
const updatedDomains = [...domains, newDomain.trim()];
setDomains(updatedDomains);
form.setValues({ newDomain: '' });
onSubmit({ domains: updatedDomains });
};
const removeDomain = (index: number) => {
const updatedDomains = domains.filter((_, i) => i !== index);
setDomains(updatedDomains);
onSubmit({ domains: updatedDomains });
};
return (
<Paper withBorder p='sm' pos='relative'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>Domains</Title>
<Group mt='md' align='flex-end'>
<TextInput
label='Domain'
description='Enter a domain name (e.g. example.com)'
placeholder='example.com'
{...form.getInputProps('newDomain')}
/>
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
Add Domain
</Button>
</Group>
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='xs'>
{domains.map((domain, index) => (
<Paper key={index} withBorder p='xs'>
<Group justify='space-between'>
<div>
<strong>{domain}</strong>
</div>
<Button
variant='subtle'
color='red'
size='xs'
onClick={() => removeDomain(index)}
px={8}
style={{
aspectRatio: '1/1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconTrash size='1rem' />
</Button>
</Group>
</Paper>
))}
</SimpleGrid>
</Paper>
);
}

View File

@@ -11,7 +11,6 @@ import {
Stack,
Switch,
Text,
TextInput,
} from '@mantine/core';
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
import Link from 'next/link';
@@ -105,10 +104,22 @@ export default function GeneratorButton({
);
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
const onlyFile = generatorType === 'file';
const domains = Array.isArray(settingsData?.settings.domains)
? settingsData?.settings.domains.map((d) => String(d))
: [];
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
value: domain,
label: domain,
})),
] as { value: string; label: string; disabled?: boolean }[];
return (
<>
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name} Uploader`}>
@@ -187,14 +198,21 @@ export default function GeneratorButton({
onChange={(value) => setOption({ maxViews: value === '' ? null : Number(value) })}
/>
<TextInput
<Select
data={domainOptions}
label='Override Domain'
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
value={options.overrides_returnDomain ?? ''}
onChange={(event) =>
setOption({ overrides_returnDomain: event.currentTarget.value.trim() || null })
}
onChange={(value) => setOption({ overrides_returnDomain: value || null })}
comboboxProps={{
withinPortal: true,
portalProps: {
style: {
zIndex: 100000000,
},
},
}}
/>
<Text c='dimmed' size='sm'>

View File

@@ -62,9 +62,20 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const combobox = useCombobox();
const [folderSearch, setFolderSearch] = useState('');
const domains = Array.isArray(settingsData?.settings.domains) ? settingsData.settings.domains : [];
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
value: domain,
label: domain,
})),
];
useEffect(() => {
if (folder) return;
@@ -264,9 +275,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Option defaultChecked={true} value='no folder'>
No Folder
</Combobox.Option>
<Combobox.Option value='no folder'>No Folder</Combobox.Option>
{folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
@@ -279,7 +288,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
</Combobox.Dropdown>
</Combobox>
<TextInput
<Select
data={domainOptions}
label={
<>
Override Domain{' '}
@@ -293,12 +303,15 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
value={options.overrides_returnDomain ?? ''}
onChange={(event) =>
setOption(
'overrides_returnDomain',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
onChange={(value) => setOption('overrides_returnDomain', value || null)}
comboboxProps={{
withinPortal: true,
portalProps: {
style: {
zIndex: 100000000,
},
},
}}
/>
<TextInput

View File

@@ -51,6 +51,7 @@ export const DATABASE_TO_PROP = {
invitesEnabled: 'invites.enabled',
invitesLength: 'invites.length',
domains: 'domains',
websiteTitle: 'website.title',
websiteTitleLogo: 'website.titleLogo',

View File

@@ -295,6 +295,11 @@ export default fastifyPlugin(
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})/),
domains: z.union([
z.array(z.string()),
z.string().transform((value) => value.split(',').map((s) => s.trim())),
]),
})
.partial()
.refine(

View File

@@ -59,7 +59,7 @@ export default fastifyPlugin(
});
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
return res.forbidden(
`shortenning this url would exceed your quota of ${req.user.quota.maxUrls} urls`,
`Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`,
);
let maxViews: number | undefined;