Merge branch 'main' into perm-x

This commit is contained in:
Philipinho
2026-02-22 01:57:16 +00:00
36 changed files with 324 additions and 495 deletions
+2
View File
@@ -26,6 +26,7 @@
"@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.5",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
@@ -59,6 +60,7 @@
"devDependencies": {
"@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1",
"@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
@@ -357,6 +357,9 @@
"Multiple": "Mehrere",
"Turn into": "In verwandeln",
"Text align": "Text ausrichten",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Überschrift {{level}}",
"Toggle title": "Titel umschalten",
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
@@ -357,6 +357,9 @@
"Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Heading {{level}}",
"Toggle title": "Toggle title",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
@@ -357,6 +357,9 @@
"Multiple": "Múltiple",
"Turn into": "Convertir en",
"Text align": "Alineación del texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Encabezado {{level}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
@@ -357,6 +357,9 @@
"Multiple": "Multiple",
"Turn into": "Transformer en",
"Text align": "Alignement du texte",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Titre {{level}}",
"Toggle title": "Basculer le titre",
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
@@ -357,6 +357,9 @@
"Multiple": "Multiplo",
"Turn into": "Trasforma in",
"Text align": "Allinea testo",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Intestazione {{level}}",
"Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
@@ -357,6 +357,9 @@
"Multiple": "複数",
"Turn into": "変換する",
"Text align": "テキストの配置",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える",
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
@@ -357,6 +357,9 @@
"Multiple": "복제",
"Turn into": "변경하기",
"Text align": "텍스트 정렬",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "제목 {{level}}",
"Toggle title": "제목 토글",
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
@@ -357,6 +357,9 @@
"Multiple": "Meerdere",
"Turn into": "Omzetten naar",
"Text align": "Tekstuitlijning",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Kop {{level}}",
"Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
@@ -357,6 +357,9 @@
"Multiple": "Múltiplo",
"Turn into": "Transformar em",
"Text align": "Alinhar texto",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Título {{level}}",
"Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
@@ -357,6 +357,9 @@
"Multiple": "Несколько",
"Turn into": "Преобразовать в",
"Text align": "Выравнивание текста",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
@@ -357,6 +357,9 @@
"Multiple": "Декілька",
"Turn into": "Перетворити",
"Text align": "Вирівнювання тексту",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
@@ -357,6 +357,9 @@
"Multiple": "多个",
"Turn into": "变成",
"Text align": "文本对齐",
"This page may have been deleted, moved, or you may not have access.": "This page may have been deleted, moved, or you may not have access.",
"Go to homepage": "Go to homepage",
"Pages you create will show up here.": "Pages you create will show up here.",
"Heading {{level}}": "{{level}} 级标题",
"Toggle title": "切换标题",
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
+1 -8
View File
@@ -14,7 +14,6 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
@@ -84,13 +83,7 @@ export default function App() {
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>
}
element={<Page />}
/>
<Route path={"/settings"}>
@@ -11,7 +11,8 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
@@ -85,8 +86,10 @@ export default function RecentChanges({ spaceId }: Props) {
</Table>
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
{t("No pages yet")}
</Text>
<EmptyState
icon={IconFiles}
title={t("No pages yet")}
description={t("Pages you create will show up here.")}
/>
);
}
@@ -0,0 +1,8 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
@@ -0,0 +1,30 @@
import { Stack, Text } from "@mantine/core";
import { type TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import classes from "./empty-state.module.css";
type EmptyStateProps = {
icon: TablerIcon;
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className={classes.root}>
<Stack align="center" gap="xs">
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500}>
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={350}>
{description}
</Text>
)}
{action}
</Stack>
</div>
);
}
@@ -7,7 +7,7 @@ import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
@@ -59,13 +59,13 @@ export function LdapLoginModal({
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
onClose();
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err: any) {
setIsLoading(false);
@@ -18,7 +18,7 @@ import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
@@ -53,7 +53,7 @@ export function MfaChallenge() {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} catch (error: any) {
setIsLoading(false);
notifications.show({
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
};
return (
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
@@ -13,8 +13,10 @@ export function useMfaPageProtection() {
const checkAccess = async () => {
const result = await validateMfaAccess();
const search = location.search;
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN);
navigate(APP_ROUTE.AUTH.LOGIN + search);
return;
}
@@ -26,17 +28,17 @@ export function useMfaPageProtection() {
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} else {
setIsValid(true);
}
@@ -1,20 +1,62 @@
import api from "@/lib/api-client";
import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
async function compressAndResizeIcon(
file: File,
type: AvatarIconType,
): Promise<File> {
const isPng = file.type === "image/png";
const { image: canvas } = await loadImage(file, {
maxWidth: 300,
maxHeight: 300,
canvas: true,
orientation: true,
imageSmoothingQuality: "high",
});
if (type === AvatarIconType.AVATAR || !isPng) {
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "source-over";
}
const outputType = isPng ? "image/png" : "image/jpeg";
return new Promise<File>((resolve, reject) => {
(canvas as HTMLCanvasElement).toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to compress image"));
return;
}
resolve(new File([blob], file.name, { type: outputType }));
},
outputType,
isPng ? undefined : 0.85,
);
});
}
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const processed = await compressAndResizeIcon(file, type);
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
formData.append("image", processed);
return await api.post("/attachments/upload-image", formData, {
headers: {
@@ -23,7 +23,7 @@ import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@@ -44,11 +44,11 @@ export default function useAuth() {
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err) {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export function useRedirectIfAuthenticated() {
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
useEffect(() => {
if (data && data?.user) {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
}, [isLoading, data]);
}
@@ -16,7 +16,7 @@ import {
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "@/features/page/tree/styles/tree.module.css";
import { ActionIcon, Box, Menu, rem } from "@mantine/core";
import { ActionIcon, Box, Menu, rem, Text } from "@mantine/core";
import {
IconArrowRight,
IconChevronDown,
@@ -82,6 +82,7 @@ interface SpaceTreeProps {
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
@@ -231,11 +232,18 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
};
}, [setTreeApi]);
const filteredData = data.filter((node) => node?.spaceId === spaceId);
return (
<div ref={mergedRef} className={classes.treeContainer}>
{isDataLoaded && filteredData.length === 0 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
{isRootReady && rootElement.current && (
<Tree
data={data.filter((node) => node?.spaceId === spaceId)}
data={filteredData}
disableDrag={readOnly}
disableDrop={readOnly}
disableEdit={readOnly}
+5 -1
View File
@@ -68,10 +68,14 @@ function redirectToLogin() {
APP_ROUTE.AUTH.SIGNUP,
APP_ROUTE.AUTH.FORGOT_PASSWORD,
APP_ROUTE.AUTH.PASSWORD_RESET,
APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
window.location.href = APP_ROUTE.AUTH.LOGIN;
const redirectTo = window.location.pathname;
const params = new URLSearchParams({ redirect: redirectTo });
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`;
}
}
+16
View File
@@ -29,4 +29,20 @@ const APP_ROUTE = {
},
};
export function getPostLoginRedirect(): string {
const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
if (redirect) {
try {
const resolved = new URL(redirect, window.location.origin);
if (resolved.origin === window.location.origin) {
return resolved.pathname + resolved.search + resolved.hash;
}
} catch {
// malformed URL, fall through to default
}
}
return APP_ROUTE.HOME;
}
export default APP_ROUTE;
+48 -2
View File
@@ -8,6 +8,11 @@ import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useTranslation } from "react-i18next";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { usePagePermission } from "@/ee/page-permission";
const MemoizedFullEditor = React.memo(FullEditor);
@@ -18,6 +23,29 @@ export default function Page() {
const { t } = useTranslation();
const { pageSlug } = useParams();
return (
<ErrorBoundary
resetKeys={[pageSlug]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load page. An error occurred.")}
action={
<Button variant="default" size="sm" mt="xs" onClick={resetErrorBoundary}>
{t("Try again")}
</Button>
}
/>
)}
>
<PageContent pageSlug={pageSlug} />
</ErrorBoundary>
);
}
function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { t } = useTranslation();
const {
data: page,
isLoading,
@@ -34,9 +62,27 @@ export default function Page() {
if (isError || !page) {
if ([401, 403, 404].includes(error?.["status"])) {
return <div>{t("Page not found")}</div>;
return (
<EmptyState
icon={IconFileOff}
title={t("Page not found")}
description={t(
"This page may have been deleted, moved, or you may not have access.",
)}
action={
<Button component={Link} to="/home" variant="default" size="sm" mt="xs">
{t("Go to homepage")}
</Button>
}
/>
);
}
return <div>{t("Error fetching page data.")}</div>;
return (
<EmptyState
icon={IconFileOff}
title={t("Error fetching page data.")}
/>
);
}
if (!space) {
+6 -7
View File
@@ -30,9 +30,9 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@ai-sdk/google": "^3.0.9",
"@ai-sdk/openai": "^3.0.11",
"@ai-sdk/openai-compatible": "^2.0.12",
"@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.982.0",
"@aws-sdk/lib-storage": "3.982.0",
"@aws-sdk/s3-request-presigner": "3.982.0",
@@ -61,11 +61,11 @@
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^7.2.7",
"cache-manager": "^7.2.8",
"cheerio": "^1.1.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
@@ -102,7 +102,6 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sharp": "0.34.3",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tmp-promise": "^3.0.3",
@@ -2,7 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
import * as path from 'path';
import { AttachmentType } from './attachment.constants';
import { sanitizeFileName } from '../../common/helpers';
import * as sharp from 'sharp';
import { getMimeType } from '../../common/helpers';
export interface PreparedFile {
buffer?: Buffer;
@@ -41,7 +41,7 @@ export async function prepareFile(
fileName,
fileSize,
fileExtension,
mimeType: file.mimetype,
mimeType: getMimeType(file.filename),
multiPartFile: file,
};
} catch (error) {
@@ -77,51 +77,3 @@ export function getAttachmentFolderPath(
}
export const validAttachmentTypes = Object.values(AttachmentType);
export async function compressAndResizeIcon(
buffer: Buffer,
attachmentType?: AttachmentType,
): Promise<Buffer> {
try {
let sharpInstance = sharp(buffer);
const metadata = await sharpInstance.metadata();
const targetWidth = 300;
const targetHeight = 300;
// Only resize if image is larger than target dimensions
if (metadata.width > targetWidth || metadata.height > targetHeight) {
sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Handle based on original format
if (metadata.format === 'png') {
// Only flatten avatars to remove transparency
if (attachmentType === AttachmentType.Avatar) {
sharpInstance = sharpInstance.flatten({
background: { r: 255, g: 255, b: 255 },
});
}
return await sharpInstance
.png({
quality: 85,
compressionLevel: 6,
})
.toBuffer();
} else {
return await sharpInstance
.jpeg({
quality: 85,
progressive: true,
mozjpeg: true,
})
.toBuffer();
}
} catch (err) {
throw err;
}
}
@@ -8,7 +8,6 @@ import { Readable } from 'stream';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
compressAndResizeIcon,
getAttachmentFolderPath,
PreparedFile,
prepareFile,
@@ -154,12 +153,6 @@ export class AttachmentService {
const preparedFile: PreparedFile = await prepareFile(filePromise);
validateFileType(preparedFile.fileExtension, validImageExtensions);
const processedBuffer = await compressAndResizeIcon(
preparedFile.buffer,
type,
);
preparedFile.buffer = processedBuffer;
preparedFile.fileSize = processedBuffer.length;
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
+2 -51
View File
@@ -1,55 +1,6 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
Notifications,
PageAccess,
PageHistory,
PagePermissions,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
Watchers,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
notifications: Notifications;
pageAccess: PageAccess;
export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -91,7 +91,6 @@ export class EnvironmentVariables {
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
TYPESENSE_URL: string;
@IsOptional()
@ValidateIf((obj) => obj.SEARCH_DRIVER === 'typesense')
@IsNotEmpty()
@IsString()
@@ -110,18 +109,14 @@ export class EnvironmentVariables {
AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
AI_EMBEDDING_MODEL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_EMBEDDING_DIMENSION)
@IsIn(['768', '1024', '1536', '2000', '3072'])
@IsString()
AI_EMBEDDING_DIMENSION: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsString()
@IsNotEmpty()
@@ -145,13 +140,11 @@ export class EnvironmentVariables {
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OPENAI_API_URL: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'gemini')
@IsString()
@IsNotEmpty()
GEMINI_API_KEY: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER && obj.AI_DRIVER === 'ollama')
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
OLLAMA_API_URL: string;