diff --git a/package.json b/package.json
index e4949246..788166f6 100644
--- a/package.json
+++ b/package.json
@@ -28,9 +28,9 @@
"@mantine/next": "^6.0.14",
"@mantine/notifications": "^6.0.14",
"@mantine/nprogress": "^6.0.14",
- "@prisma/client": "4.16.1",
- "@prisma/internals": "^4.16.1",
- "@prisma/migrate": "^4.16.1",
+ "@prisma/client": "^5.0.0",
+ "@prisma/internals": "^5.0.0",
+ "@prisma/migrate": "^5.0.0",
"@tabler/icons-react": "^2.23.0",
"argon2": "^0.30.3",
"bytes": "^3.1.2",
@@ -38,6 +38,7 @@
"dayjs": "^1.11.8",
"express": "^4.18.2",
"highlight.js": "^11.8.0",
+ "isomorphic-dompurify": "^1.8.0",
"katex": "^0.16.8",
"mantine-datatable": "^2.8.2",
"ms": "^2.1.3",
@@ -67,7 +68,7 @@
"dotenv": "^16.1.3",
"eslint": "^8.41.0",
"npm-run-all": "^4.1.5",
- "prisma": "^4.16.1",
+ "prisma": "^5.0.0",
"tsup": "^7.0.0",
"typescript": "^5.1.3"
},
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 26ba0871..c7c0115c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -20,11 +20,12 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- username String @unique
- password String?
- avatar String?
- token String @unique
- role Role @default(USER)
+ username String @unique
+ password String?
+ avatar String?
+ token String @unique
+ role Role @default(USER)
+ view Json @default("{}")
files File[]
urls Url[]
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 49402641..6d091110 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -44,6 +44,7 @@ import { useRouter } from 'next/router';
import { useState } from 'react';
import ConfigProvider from './ConfigProvider';
import { isAdministrator } from '@/lib/role';
+import useAvatar from '@/lib/hooks/useAvatar';
type NavLinks = {
label: string;
@@ -117,6 +118,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
const [setUser, setToken] = useUserStore((s) => [s.setUser, s.setToken]);
const { user, mutate } = useLogin();
+ const { avatar } = useAvatar();
const copyToken = () => {
modals.openConfirmModal({
@@ -279,9 +281,11 @@ export default function Layout({ children, config }: { children: React.ReactNode
variant='subtle'
color='gray'
leftIcon={
-
-
-
+ avatar ? (
+
+ ) : (
+
+ )
}
rightIcon={}
size='sm'
diff --git a/src/components/file/DashboardFileType.tsx b/src/components/file/DashboardFileType.tsx
index 1ed229e1..108497f0 100644
--- a/src/components/file/DashboardFileType.tsx
+++ b/src/components/file/DashboardFileType.tsx
@@ -46,27 +46,36 @@ export default function DashboardFileType({
show,
password,
disableMediaPreview,
+ code,
}: {
file: DbFile | File;
show?: boolean;
password?: string;
disableMediaPreview?: boolean;
+ code?: boolean;
}) {
- const type = file.type.split('/')[0];
const dbFile = 'id' in file;
const renderIn = renderMode(file.name.split('.').pop() || '');
const [fileContent, setFileContent] = useState('');
+ const [type, setType] = useState(file.type.split('/')[0]);
+
+ const gettext = async () => {
+ const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
+ const text = await res.text();
+
+ setFileContent(text);
+ };
useEffect(() => {
- if (type !== 'text') return;
-
- (async () => {
- const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
- const text = await res.text();
-
- setFileContent(text);
- })();
+ if (code) {
+ setType('text');
+ gettext();
+ } else if (type === 'text') {
+ gettext();
+ } else {
+ return;
+ }
}, []);
if (disableMediaPreview)
diff --git a/src/components/pages/files/useApiPagination.tsx b/src/components/pages/files/useApiPagination.tsx
index 7eee51b9..bc6c6dd2 100644
--- a/src/components/pages/files/useApiPagination.tsx
+++ b/src/components/pages/files/useApiPagination.tsx
@@ -45,7 +45,7 @@ export function useApiPagination(
page: 1,
}
) {
- const { data, error, isLoading, mutate } = useSWR(
+ const { data, error, isLoading, mutate } = useSWR(
{ key: `/api/user/files`, options },
fetcher
);
diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx
new file mode 100644
index 00000000..feb017f3
--- /dev/null
+++ b/src/components/pages/settings/index.tsx
@@ -0,0 +1,31 @@
+import { Group, SimpleGrid, Title } from '@mantine/core';
+import SettingsAvatar from './parts/SettingsAvatar';
+import SettingsDashboard from './parts/SettingsDashboard';
+import SettingsUser from './parts/SettingsUser';
+import SettingsFileView from './parts/SettingsFileView';
+import { useConfig } from '@/components/ConfigProvider';
+import SettingsOAuth from './parts/SettingsOAuth';
+
+export default function DashboardSettings() {
+ const config = useConfig();
+
+ return (
+ <>
+
+ Settings
+
+
+
+
+
+
+
+
+
+
+
+ {config.features.oauthRegistration && }
+
+ >
+ );
+}
diff --git a/src/components/pages/settings/parts/SettingsAvatar.tsx b/src/components/pages/settings/parts/SettingsAvatar.tsx
new file mode 100644
index 00000000..f2434c47
--- /dev/null
+++ b/src/components/pages/settings/parts/SettingsAvatar.tsx
@@ -0,0 +1,148 @@
+import { Response } from '@/lib/api/response';
+import { fetchApi } from '@/lib/fetchApi';
+import useAvatar from '@/lib/hooks/useAvatar';
+import { readToDataURL } from '@/lib/readToDataURL';
+import { useUserStore } from '@/lib/store/user';
+import { Avatar, Button, Card, FileInput, Group, Paper, Stack, Text, Title } from '@mantine/core';
+import { notifications } from '@mantine/notifications';
+import { IconChevronDown, IconPhoto, IconPhotoCancel, IconSettingsFilled } from '@tabler/icons-react';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import useSWR from 'swr';
+
+export default function SettingsAvatar() {
+ const router = useRouter();
+ const user = useUserStore((state) => state.user);
+
+ const { avatar: currentAvatar, mutate } = useAvatar();
+ const [avatar, setAvatar] = useState(null);
+ const [avatarSrc, setAvatarSrc] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ if (!avatar) return;
+
+ const base64url = await readToDataURL(avatar);
+ setAvatarSrc(base64url);
+ })();
+ }, [avatar]);
+
+ const saveAvatar = async () => {
+ if (!avatar) return;
+
+ const base64url = await readToDataURL(avatar);
+ const { data, error } = await fetchApi(`/api/user`, 'PATCH', {
+ avatar: base64url,
+ });
+
+ if (!data && error) {
+ notifications.show({
+ title: 'Error while updating avatar',
+ message: error.message,
+ color: 'red',
+ icon: ,
+ });
+
+ return;
+ }
+
+ notifications.show({
+ message: 'Avatar updated',
+ color: 'green',
+ icon: ,
+ });
+
+ setAvatar(null);
+ setAvatarSrc(null);
+ mutate(base64url);
+ };
+
+ const clearAvatar = async () => {
+ const { data, error } = await fetchApi(`/api/user`, 'PATCH', {
+ avatar: null,
+ });
+
+ if (!data && error) {
+ notifications.show({
+ title: 'Error while updating avatar',
+ message: error.message,
+ color: 'red',
+ icon: ,
+ });
+
+ return;
+ }
+
+ notifications.show({
+ message: 'Avatar updated',
+ color: 'green',
+ icon: ,
+ });
+
+ setAvatar(null);
+ setAvatarSrc(null);
+ mutate(undefined);
+ };
+
+ return (
+
+ Avatar
+
+
+ setAvatar(file)}
+ />
+
+
+
+ Preview of {avatar ? 'new' : 'current'} avatar
+
+
+
+ ) : currentAvatar ? (
+
+ ) : (
+
+ )
+ }
+ rightIcon={}
+ size='sm'
+ >
+ {user?.username}
+
+
+
+
+ {avatarSrc && (
+
+ )}
+ {currentAvatar && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/pages/settings/parts/SettingsDashboard.tsx b/src/components/pages/settings/parts/SettingsDashboard.tsx
new file mode 100644
index 00000000..6475c595
--- /dev/null
+++ b/src/components/pages/settings/parts/SettingsDashboard.tsx
@@ -0,0 +1,30 @@
+import { useSettingsStore } from '@/lib/store/settings';
+import { Paper, Stack, Switch, Text, Title } from '@mantine/core';
+
+export default function SettingsDashboard() {
+ const [settings, update] = useSettingsStore((state) => [state.settings, state.update]);
+
+ return (
+
+ Dashboard Settings
+
+ These settings are saved in your browser.
+
+
+
+ update('disableMediaPreview', event.currentTarget.checked)}
+ />
+ update('warnDeletion', event.currentTarget.checked)}
+ />
+
+
+ );
+}
diff --git a/src/components/pages/settings/parts/SettingsFileView.tsx b/src/components/pages/settings/parts/SettingsFileView.tsx
new file mode 100644
index 00000000..01691558
--- /dev/null
+++ b/src/components/pages/settings/parts/SettingsFileView.tsx
@@ -0,0 +1,173 @@
+import { Response } from '@/lib/api/response';
+import { fetchApi } from '@/lib/fetchApi';
+import { useUserStore } from '@/lib/store/user';
+import {
+ ActionIcon,
+ Button,
+ ColorInput,
+ CopyButton,
+ Divider,
+ Group,
+ Paper,
+ PasswordInput,
+ ScrollArea,
+ Select,
+ SimpleGrid,
+ Stack,
+ Switch,
+ Text,
+ TextInput,
+ Textarea,
+ Title,
+ Tooltip,
+} from '@mantine/core';
+import { hasLength, useForm } from '@mantine/form';
+import { notifications } from '@mantine/notifications';
+import { IconCheck, IconCopy, IconFileX, IconUserCancel } from '@tabler/icons-react';
+import { forwardRef, useEffect, useState } from 'react';
+import { mutate } from 'swr';
+
+export default function SettingsFileView() {
+ const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
+
+ const form = useForm({
+ initialValues: {
+ enabled: user?.view.enabled ?? false,
+ content: user?.view.content ?? '',
+ embed: user?.view.embed ?? false,
+ embedTitle: user?.view.embedTitle ?? '',
+ embedDescription: user?.view.embedDescription ?? '',
+ embedSiteName: user?.view.embedSiteName ?? '',
+ embedColor: user?.view.embedColor ?? '',
+ align: user?.view.align ?? 'left',
+ showMimetype: user?.view.showMimetype ?? false,
+ },
+ });
+
+ const onSubmit = async (values: typeof form.values) => {
+ const valuesTrimmed = {
+ enabled: values.enabled,
+ embed: values.embed,
+ content: values.content.trim() || null,
+ embedTitle: values.embedTitle.trim() || null,
+ embedDescription: values.embedDescription.trim() || null,
+ embedSiteName: values.embedSiteName.trim() || null,
+ embedColor: values.embedColor.trim() || null,
+ align: values.align,
+ showMimetype: values.showMimetype,
+ };
+
+ const { data, error } = await fetchApi(`/api/user`, 'PATCH', {
+ view: valuesTrimmed,
+ });
+
+ if (!data && error) {
+ notifications.show({
+ title: 'Error while updating view settings',
+ message: error.message,
+ color: 'red',
+ icon: ,
+ });
+ }
+
+ if (!data?.user) return;
+
+ mutate('/api/user');
+ setUser(data.user);
+ notifications.show({
+ message: 'View settings updated',
+ color: 'green',
+ icon: ,
+ });
+ };
+
+ return (
+
+ Viewing Files
+
+ All text fields support using variables.
+
+
+
+
+
+ );
+}
diff --git a/src/components/pages/settings/parts/SettingsOAuth.tsx b/src/components/pages/settings/parts/SettingsOAuth.tsx
new file mode 100644
index 00000000..a793dbb3
--- /dev/null
+++ b/src/components/pages/settings/parts/SettingsOAuth.tsx
@@ -0,0 +1,79 @@
+import { useConfig } from '@/components/ConfigProvider';
+import { findProvider } from '@/lib/oauth/providerUtil';
+import { useSettingsStore } from '@/lib/store/settings';
+import { useUserStore } from '@/lib/store/user';
+import { Button, Group, Paper, Stack, Switch, Text, Title } from '@mantine/core';
+import type { OAuthProviderType } from '@prisma/client';
+import {
+ IconBrandDiscordFilled,
+ IconBrandGithubFilled,
+ IconBrandGoogle,
+ IconCircleKeyFilled,
+} from '@tabler/icons-react';
+import Link from 'next/link';
+
+const icons = {
+ DISCORD: ,
+ GITHUB: ,
+ GOOGLE: ,
+ AUTHENTIK: ,
+};
+
+const names = {
+ DISCORD: 'Discord',
+ GITHUB: 'GitHub',
+ GOOGLE: 'Google',
+ AUTHENTIK: 'Authentik',
+};
+
+function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked: boolean }) {
+ const unlink = async () => {};
+
+ const baseProps = {
+ size: 'sm',
+ leftIcon: icons[provider],
+ color: linked ? 'red' : `${provider.toLowerCase()}.0`,
+ sx: (t: any) => ({
+ '&:hover': {
+ ...(!linked && { backgroundColor: t.fn.darken(t.colors[provider.toLowerCase()][0], 0.2) }),
+ },
+ }),
+ };
+
+ return linked ? (
+
+ ) : (
+
+ );
+}
+
+export default function SettingsOAuth() {
+ const config = useConfig();
+
+ const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
+
+ const discordLinked = findProvider('DISCORD', user?.oauthProviders ?? []);
+ const githubLinked = findProvider('GITHUB', user?.oauthProviders ?? []);
+ const googleLinked = findProvider('GOOGLE', user?.oauthProviders ?? []);
+ const authentikLinked = findProvider('AUTHENTIK', user?.oauthProviders ?? []);
+
+ return (
+
+ OAuth
+
+ Manage your connected OAuth providers.
+
+
+
+ {config.oauthEnabled.discord && }
+ {config.oauthEnabled.github && }
+ {config.oauthEnabled.google && }
+ {config.oauthEnabled.authentik && }
+
+
+ );
+}
diff --git a/src/components/pages/settings/parts/SettingsUser.tsx b/src/components/pages/settings/parts/SettingsUser.tsx
new file mode 100644
index 00000000..7f280f9a
--- /dev/null
+++ b/src/components/pages/settings/parts/SettingsUser.tsx
@@ -0,0 +1,125 @@
+import { Response } from '@/lib/api/response';
+import { fetchApi } from '@/lib/fetchApi';
+import { useUserStore } from '@/lib/store/user';
+import {
+ ActionIcon,
+ Button,
+ CopyButton,
+ Group,
+ Paper,
+ PasswordInput,
+ ScrollArea,
+ Stack,
+ TextInput,
+ Title,
+ Tooltip,
+} from '@mantine/core';
+import { hasLength, useForm } from '@mantine/form';
+import { notifications } from '@mantine/notifications';
+import { IconCheck, IconCopy, IconUserCancel } from '@tabler/icons-react';
+import { useEffect, useState } from 'react';
+import { mutate } from 'swr';
+
+export default function SettingsUser() {
+ const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
+
+ const [tokenShown, setTokenShown] = useState(false);
+ const [token, setToken] = useState('');
+
+ useEffect(() => {
+ (async () => {
+ const { data, error } = await fetchApi('/api/user/token');
+
+ if (data) {
+ setToken(data.token || '');
+ }
+ })();
+ }, []);
+
+ const form = useForm({
+ initialValues: {
+ username: user?.username ?? '',
+ password: '',
+ },
+ validate: {
+ username: hasLength({ min: 1 }, 'Username is required'),
+ },
+ });
+
+ const onSubmit = async (values: typeof form.values) => {
+ const send: {
+ username?: string;
+ password?: string;
+ } = {};
+
+ if (values.username !== user?.username) send['username'] = values.username.trim();
+ if (values.password) send['password'] = values.password.trim();
+
+ const { data, error } = await fetchApi(`/api/user`, 'PATCH', send);
+
+ if (!data && error) {
+ if (error.field === 'username') {
+ form.setFieldError('username', error.message);
+ } else {
+ notifications.show({
+ title: 'Error while updating user',
+ message: error.message,
+ color: 'red',
+ icon: ,
+ });
+ }
+
+ return;
+ }
+
+ if (!data?.user) return;
+
+ mutate('/api/user');
+ setUser(data.user);
+ notifications.show({
+ message: 'User updated',
+ color: 'green',
+ icon: ,
+ });
+ };
+
+ return (
+
+ User info
+
+
+ );
+}
diff --git a/src/lib/config/safe.ts b/src/lib/config/safe.ts
index ece40644..17eaa3e8 100644
--- a/src/lib/config/safe.ts
+++ b/src/lib/config/safe.ts
@@ -1,10 +1,15 @@
import { config } from '.';
+import enabled from '../oauth/enabled';
import { Config } from './validate';
-export type SafeConfig = Omit;
+export type SafeConfig = Omit & {
+ oauthEnabled: ReturnType;
+};
export function safeConfig(): SafeConfig {
const { datasource, core, oauth, ...rest } = config;
- return rest;
+ (rest as SafeConfig).oauthEnabled = enabled(config);
+
+ return rest as SafeConfig;
}
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 8897f219..84350965 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -1,10 +1,11 @@
-import { PrismaClient } from '@prisma/client';
import { log } from '@/lib/logger';
+import { Prisma, PrismaClient } from '@prisma/client';
+import { userViewSchema } from './models/user';
-let prisma: PrismaClient;
+let prisma: ExtendedPrismaClient;
declare global {
- var __db__: PrismaClient;
+ var __db__: ExtendedPrismaClient;
}
if (process.env.NODE_ENV === 'production') {
@@ -16,12 +17,25 @@ if (process.env.NODE_ENV === 'production') {
prisma = global.__db__;
}
+type ExtendedPrismaClient = ReturnType;
+
function getClient() {
const logger = log('db');
logger.info('connecting to database ' + process.env.DATABASE_URL);
- const client = new PrismaClient();
+ const client = new PrismaClient().$extends({
+ result: {
+ user: {
+ view: {
+ needs: { view: true },
+ compute({ view }: { view: Prisma.JsonValue }) {
+ return userViewSchema.parse(view);
+ },
+ },
+ },
+ },
+ });
client.$connect();
return client;
diff --git a/src/lib/db/models/user.ts b/src/lib/db/models/user.ts
index 21014b44..b24c1297 100644
--- a/src/lib/db/models/user.ts
+++ b/src/lib/db/models/user.ts
@@ -1,9 +1,16 @@
+import { OAuthProvider } from '@prisma/client';
+import { z } from 'zod';
+
export type User = {
id: string;
username: string;
createdAt: Date;
updatedAt: Date;
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
+ view: UserViewSettings;
+
+ oauthProviders: OAuthProvider[];
+
avatar?: string | null;
password?: string | null;
token?: string | null;
@@ -15,4 +22,21 @@ export const userSelect = {
createdAt: true,
updatedAt: true,
role: true,
+ view: true,
+ oauthProviders: true,
};
+
+export type UserViewSettings = z.infer;
+export const userViewSchema = z
+ .object({
+ enabled: z.boolean().nullish(),
+ align: z.enum(['left', 'center', 'right']).nullish(),
+ showMimetype: z.boolean().nullish(),
+ content: z.string().nullish(),
+ embed: z.boolean().nullish(),
+ embedTitle: z.string().nullish(),
+ embedDescription: z.string().nullish(),
+ embedColor: z.string().nullish(),
+ embedSiteName: z.string().nullish(),
+ })
+ .partial();
diff --git a/src/lib/hooks/useAvatar.ts b/src/lib/hooks/useAvatar.ts
new file mode 100644
index 00000000..ca354528
--- /dev/null
+++ b/src/lib/hooks/useAvatar.ts
@@ -0,0 +1,14 @@
+import useSWR from 'swr';
+
+const f = async () => {
+ const res = await fetch('/api/user/avatar');
+ if (!res.ok) return null;
+
+ const r = await res.text();
+ return r;
+};
+
+export default function useAvatar() {
+ const { data, mutate } = useSWR('/api/user/avatar', f);
+ return { avatar: data, mutate };
+}
diff --git a/src/lib/oauth/enabled.ts b/src/lib/oauth/enabled.ts
new file mode 100644
index 00000000..5f3b859e
--- /dev/null
+++ b/src/lib/oauth/enabled.ts
@@ -0,0 +1,38 @@
+import { Config } from '../config/validate';
+import { isTruthy } from '../primitive';
+
+export default function enabled(config: Config) {
+ const discordEnabled = isTruthy(
+ config.oauth?.discord?.clientId,
+ config.oauth?.discord?.clientSecret,
+ config.features.oauthRegistration
+ );
+
+ const githubEnabled = isTruthy(
+ config.oauth?.github?.clientId,
+ config.oauth?.github?.clientSecret,
+ config.features.oauthRegistration
+ );
+
+ const googleEnabled = isTruthy(
+ config.oauth?.google?.clientId,
+ config.oauth?.google?.clientSecret,
+ config.features.oauthRegistration
+ );
+
+ const authentikEnabled = isTruthy(
+ config.oauth?.authentik?.clientId,
+ config.oauth?.authentik?.clientSecret,
+ config.oauth?.authentik?.authorizeUrl,
+ config.oauth?.authentik?.tokenUrl,
+ config.oauth?.authentik?.userinfoUrl,
+ config.features.oauthRegistration
+ );
+
+ return {
+ discord: discordEnabled,
+ github: githubEnabled,
+ google: googleEnabled,
+ authentik: authentikEnabled,
+ };
+}
diff --git a/src/lib/oauth/providerUtil.ts b/src/lib/oauth/providerUtil.ts
new file mode 100644
index 00000000..8efcfe0a
--- /dev/null
+++ b/src/lib/oauth/providerUtil.ts
@@ -0,0 +1,9 @@
+import type { OAuthProviderType } from '@prisma/client';
+import { User } from '../db/models/user';
+
+export function findProvider(
+ provider: OAuthProviderType,
+ providers: User['oauthProviders']
+): User['oauthProviders'][0] | undefined {
+ return providers.find((p) => p.provider === provider);
+}
diff --git a/src/lib/parser.ts b/src/lib/parser.ts
new file mode 100644
index 00000000..96c958ba
--- /dev/null
+++ b/src/lib/parser.ts
@@ -0,0 +1,165 @@
+import bytes from 'bytes';
+import { File } from './db/models/file';
+import { User } from './db/models/user';
+
+export type ParseValue = {
+ file?: File;
+ user?: User | Omit;
+
+ link?: string;
+ raw_link?: string;
+};
+
+export function parseString(str: string, value: ParseValue) {
+ if (!str) return null;
+ str = str
+ .replace(/\{link\}/gi, value.link ?? '{unknown_link}')
+ .replace(/\{raw_link\}/gi, value.raw_link ?? '{unknown_raw_link}')
+ .replace(/\\n/g, '\n');
+
+ const re = /\{(?file|url|user)\.(?\w+)(::(?\w+))?\}/gi;
+ let matches: RegExpMatchArray | null;
+
+ while ((matches = re.exec(str))) {
+ if (!matches.groups) continue;
+
+ const index = matches.index as number;
+
+ const getV = value[matches.groups.type as keyof ParseValue];
+ if (!getV) {
+ str = replaceCharsFromString(str, '{unknown_type}', index, re.lastIndex);
+ re.lastIndex = index;
+ continue;
+ }
+
+ if (['password', 'avatar', 'uuid'].includes(matches.groups.prop)) {
+ str = replaceCharsFromString(str, '{unknown_property}', index, re.lastIndex);
+ re.lastIndex = index;
+ continue;
+ }
+
+ if (['originalName', 'name'].includes(matches.groups.prop)) {
+ str = replaceCharsFromString(
+ str,
+ decodeURIComponent(escape(getV[matches.groups.prop as keyof ParseValue['file']])),
+ index,
+ re.lastIndex
+ );
+ re.lastIndex = index;
+ continue;
+ }
+
+ const v = getV[matches.groups.prop as keyof ParseValue['file'] | keyof ParseValue['user']];
+
+ if (v === undefined) {
+ str = replaceCharsFromString(str, '{unknown_property}', index, re.lastIndex);
+ re.lastIndex = index;
+ continue;
+ }
+
+ if (matches.groups.mod) {
+ str = replaceCharsFromString(str, modifier(matches.groups.mod, v), index, re.lastIndex);
+ re.lastIndex = index;
+ continue;
+ }
+
+ str = replaceCharsFromString(str, v, index, re.lastIndex);
+ re.lastIndex = index;
+ }
+
+ return str;
+}
+
+function modifier(mod: string, value: unknown): string {
+ mod = mod.toLowerCase();
+
+ if (value instanceof Date) {
+ switch (mod) {
+ case 'locale':
+ return value.toLocaleString();
+ case 'time':
+ return value.toLocaleTimeString();
+ case 'date':
+ return value.toLocaleDateString();
+ case 'unix':
+ return Math.floor(value.getTime() / 1000).toString();
+ case 'iso':
+ return value.toISOString();
+ case 'utc':
+ return value.toUTCString();
+ case 'year':
+ return value.getFullYear().toString();
+ case 'month':
+ return (value.getMonth() + 1).toString();
+ case 'day':
+ return value.getDate().toString();
+ case 'hour':
+ return value.getHours().toString();
+ case 'minute':
+ return value.getMinutes().toString();
+ case 'second':
+ return value.getSeconds().toString();
+ default:
+ return `{unknown_date_modifier(${mod})}`;
+ }
+ } else if (typeof value === 'string') {
+ switch (mod) {
+ case 'upper':
+ return value.toUpperCase();
+ case 'lower':
+ return value.toLowerCase();
+ case 'title':
+ return value.charAt(0).toUpperCase() + value.slice(1);
+ case 'length':
+ return value.length.toString();
+ case 'reverse':
+ return value.split('').reverse().join('');
+ case 'base64':
+ return btoa(value);
+ case 'hex':
+ return toHex(value);
+ default:
+ return `{unknown_str_modifier(${mod})}`;
+ }
+ } else if (typeof value === 'number') {
+ switch (mod) {
+ case 'comma':
+ return value.toLocaleString();
+ case 'hex':
+ return value.toString(16);
+ case 'octal':
+ return value.toString(8);
+ case 'binary':
+ return value.toString(2);
+ case 'bytes':
+ return bytes(value, { unitSeparator: ' ' });
+ default:
+ return `{unknown_int_modifier(${mod})}`;
+ }
+ } else if (typeof value === 'boolean') {
+ switch (mod) {
+ case 'yesno':
+ return value ? 'Yes' : 'No';
+ case 'onoff':
+ return value ? 'On' : 'Off';
+ case 'truefalse':
+ return value ? 'True' : 'False';
+ default:
+ return `{unknown_bool_modifier(${mod})}`;
+ }
+ }
+
+ return `{unknown_modifier(${mod})}`;
+}
+
+function replaceCharsFromString(str: string, replace: string, start: number, end: number): string {
+ return str.slice(0, start) + replace + str.slice(end);
+}
+
+function toHex(str: string): string {
+ let hex = '';
+ for (let i = 0; i < str.length; i++) {
+ hex += '' + str.charCodeAt(i).toString(16);
+ }
+ return hex;
+}
diff --git a/src/lib/store/settings.ts b/src/lib/store/settings.ts
new file mode 100644
index 00000000..28719c4f
--- /dev/null
+++ b/src/lib/store/settings.ts
@@ -0,0 +1,33 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export type SettingsStore = {
+ settings: {
+ disableMediaPreview: boolean;
+ warnDeletion: boolean;
+ };
+
+ update: (key: K, value: SettingsStore['settings'][K]) => void;
+};
+
+export const useSettingsStore = create()(
+ persist(
+ (set) => ({
+ settings: {
+ disableMediaPreview: false,
+ warnDeletion: true,
+ },
+
+ update: (key, value) =>
+ set((state) => ({
+ settings: {
+ ...state.settings,
+ [key]: value,
+ },
+ })),
+ }),
+ {
+ name: 'zipline-settings',
+ }
+ )
+);
diff --git a/src/lib/url.ts b/src/lib/url.ts
index 2ef728f8..40b9a33e 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -1,3 +1,3 @@
export function formatRootUrl(route: string, src: string) {
- return `${route === '/' || route === '' ? '' : route}${encodeURI(src)}`
-}
\ No newline at end of file
+ return `${route === '/' ? '' : route}/${encodeURI(src)}`;
+}
diff --git a/src/pages/api/user/avatar.ts b/src/pages/api/user/avatar.ts
index 50226130..6edce520 100644
--- a/src/pages/api/user/avatar.ts
+++ b/src/pages/api/user/avatar.ts
@@ -25,12 +25,7 @@ export async function handler(req: NextApiReq, res: NextApiRes, res: NextApiRes) {
if (req.method === 'GET') {
return res.ok({ user: req.user, token: req.cookies.zipline_token });
} else if (req.method === 'PATCH') {
+ if (req.body.username) {
+ const existing = await prisma.user.findUnique({
+ where: {
+ username: req.body.username,
+ },
+ });
+
+ if (existing) return res.badRequest('Username already taken', { field: 'username' });
+ }
+
const user = await prisma.user.update({
where: {
id: req.user.id,
@@ -28,7 +49,27 @@ export async function handler(req: NextApiReq, res: NextApiRes) {
+export default function Login({ config }: InferGetServerSidePropsType) {
const router = useRouter();
const { data, isLoading, mutate } = useSWR('/api/user');
@@ -110,7 +96,7 @@ export default function Login({
)}
- {discordEnabled && (
+ {config.oauthEnabled.discord && (