mirror of
https://github.com/docmost/docmost.git
synced 2026-08-19 18:44:09 +08:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83faeefdbc | ||
|
|
7b8ade0968 | ||
|
|
8c4ecfaaf2 | ||
|
|
adbe671656 | ||
|
|
e0d0fb15c9 | ||
|
|
66fd84e893 | ||
|
|
6c9836925c | ||
|
|
cb373c9c05 | ||
|
|
38fddf9b5d | ||
|
|
7a2411e147 | ||
|
|
157e847550 | ||
|
|
192aadfbb4 | ||
|
|
c0e4f4cfb3 | ||
|
|
708e08037f | ||
|
|
7003c392e0 | ||
|
|
738120172d | ||
|
|
d5ad7d5181 | ||
|
|
fc34f1a7f5 | ||
|
|
911c1057d6 | ||
|
|
d136864ef1 | ||
|
|
c093c18bf3 | ||
|
|
ea59912c7e | ||
|
|
db3ff54da1 | ||
|
|
9414a38215 |
@@ -1297,5 +1297,10 @@
|
||||
"Select version from {{date}}": "Select version from {{date}}",
|
||||
"Version actions for {{date}}": "Version actions for {{date}}",
|
||||
"Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}",
|
||||
"Exit compare": "Exit compare"
|
||||
"Exit compare": "Exit compare",
|
||||
"Search attachments...": "Search attachments...",
|
||||
"Error loading attachments.": "Error loading attachments.",
|
||||
"No attachments on this page yet.": "No attachments on this page yet.",
|
||||
"Uploaded by {{name}}": "Uploaded by {{name}}",
|
||||
"Download {{name}}": "Download {{name}}"
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ export default function ChatInput({
|
||||
},
|
||||
content: "",
|
||||
editable: true,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: autofocus ? "end" : false,
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface IAiSearchResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function hintVectorCache(): Promise<void> {
|
||||
try {
|
||||
await api.post("/ai/vector-cache-hint");
|
||||
} catch {
|
||||
// best-effort cache hint
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiAnswers(
|
||||
params: IPageSearchParams,
|
||||
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={template.content}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function TemplateEditor() {
|
||||
const editor = useEditor({
|
||||
extensions: templateExtensions,
|
||||
content: "",
|
||||
textDirection: "auto",
|
||||
editorProps: {
|
||||
scrollThreshold: 80,
|
||||
scrollMargin: 80,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
IconFile,
|
||||
IconFileTypeCsv,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypePdf,
|
||||
IconFileTypePpt,
|
||||
IconFileTypeXls,
|
||||
IconFileZip,
|
||||
IconMovie,
|
||||
IconMusic,
|
||||
IconPhoto,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const EXT_ICONS: Record<string, { icon: Icon; color: string }> = {
|
||||
".pdf": { icon: IconFileTypePdf, color: "red" },
|
||||
".doc": { icon: IconFileTypeDocx, color: "blue" },
|
||||
".docx": { icon: IconFileTypeDocx, color: "blue" },
|
||||
".xls": { icon: IconFileTypeXls, color: "teal" },
|
||||
".xlsx": { icon: IconFileTypeXls, color: "teal" },
|
||||
".csv": { icon: IconFileTypeCsv, color: "teal" },
|
||||
".ppt": { icon: IconFileTypePpt, color: "orange" },
|
||||
".pptx": { icon: IconFileTypePpt, color: "orange" },
|
||||
".zip": { icon: IconFileZip, color: "gray" },
|
||||
".rar": { icon: IconFileZip, color: "gray" },
|
||||
".7z": { icon: IconFileZip, color: "gray" },
|
||||
".tar": { icon: IconFileZip, color: "gray" },
|
||||
".gz": { icon: IconFileZip, color: "gray" },
|
||||
};
|
||||
|
||||
const MIME_ICONS: Array<{ prefix: string; icon: Icon; color: string }> = [
|
||||
{ prefix: "image/", icon: IconPhoto, color: "grape" },
|
||||
{ prefix: "video/", icon: IconMovie, color: "violet" },
|
||||
{ prefix: "audio/", icon: IconMusic, color: "pink" },
|
||||
];
|
||||
|
||||
interface AttachmentFileIconProps {
|
||||
fileExt?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AttachmentFileIcon({
|
||||
fileExt,
|
||||
mimeType,
|
||||
}: AttachmentFileIconProps) {
|
||||
const byExt = fileExt ? EXT_ICONS[fileExt.toLowerCase()] : undefined;
|
||||
const byMime = mimeType
|
||||
? MIME_ICONS.find((entry) => mimeType.startsWith(entry.prefix))
|
||||
: undefined;
|
||||
const { icon: FileIcon, color } = byExt ??
|
||||
byMime ?? { icon: IconFile, color: "gray" };
|
||||
|
||||
return (
|
||||
<ThemeIcon variant="light" color={color} size={40} radius="md">
|
||||
<FileIcon size={22} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { usePageAttachmentsQuery } from "@/features/attachments/queries/attachment-query.ts";
|
||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { AttachmentFileIcon } from "@/features/attachments/components/attachment-file-icon.tsx";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
|
||||
interface PageAttachmentsModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageAttachmentsModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageAttachmentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
title={t("Attachments")}
|
||||
size={800}
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<PageAttachmentsList pageId={pageId} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PageAttachmentsList({ pageId }: { pageId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState("");
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = usePageAttachmentsQuery(pageId, search);
|
||||
|
||||
const attachments = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = loadMoreRef.current;
|
||||
if (!sentinel || !hasNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetching]);
|
||||
|
||||
const handleSearch = useCallback((value: string) => setSearch(value), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput
|
||||
onSearch={handleSearch}
|
||||
placeholder={t("Search attachments...")}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Error loading attachments.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : attachments.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Text size="sm" c="dimmed">
|
||||
{search
|
||||
? t("No results found")
|
||||
: t("No attachments on this page yet.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={480} type="scroll" scrollbarSize={5}>
|
||||
{attachments.map((attachment) => (
|
||||
<AttachmentRow key={attachment.id} attachment={attachment} />
|
||||
))}
|
||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||
{isFetchingNextPage && (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({ attachment }: { attachment: IPageAttachment }) {
|
||||
const { t } = useTranslation();
|
||||
const fileUrl = getFileUrl(attachment.url);
|
||||
|
||||
return (
|
||||
<Group wrap="nowrap" gap="md" py="sm" pr="xs">
|
||||
<AttachmentFileIcon
|
||||
fileExt={attachment.fileExt}
|
||||
mimeType={attachment.mimeType}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Anchor
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="inherit"
|
||||
truncate="end"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
{attachment.fileName}
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed" mt={2} truncate="end">
|
||||
{formatBytes(Number(attachment.fileSize))}
|
||||
{" · "}
|
||||
{formattedDate(new Date(attachment.createdAt))}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{attachment.creator && (
|
||||
<Tooltip
|
||||
label={t("Uploaded by {{name}}", { name: attachment.creator.name })}
|
||||
withArrow
|
||||
>
|
||||
<CustomAvatar
|
||||
avatarUrl={attachment.creator.avatarUrl}
|
||||
name={attachment.creator.name}
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label={t("Download attachment")} withArrow>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileUrl}
|
||||
download={attachment.fileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("Download {{name}}", { name: attachment.fileName })}
|
||||
>
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { getPageAttachments } from "@/features/attachments/services/attachment-service.ts";
|
||||
import { IPageAttachment } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
export function usePageAttachmentsQuery(
|
||||
pageId: string,
|
||||
search?: string,
|
||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPageAttachment>, unknown>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["page-attachments", pageId, search],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getPageAttachments(pageId, { cursor: pageParam, query: search }),
|
||||
enabled: !!pageId,
|
||||
gcTime: 0,
|
||||
placeholderData: keepPreviousData,
|
||||
initialPageParam: undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,17 @@ import loadImage from "blueimp-load-image";
|
||||
import {
|
||||
AvatarIconType,
|
||||
IAttachment,
|
||||
IPageAttachment,
|
||||
} from "@/features/attachments/types/attachment.types.ts";
|
||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||
|
||||
export async function getPageAttachments(
|
||||
pageId: string,
|
||||
params?: QueryParams,
|
||||
): Promise<IPagination<IPageAttachment>> {
|
||||
const req = await api.post("/pages/attachments", { pageId, ...params });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
async function compressAndResizeIcon(
|
||||
file: File,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
getPageAttachments,
|
||||
uploadIcon,
|
||||
uploadUserAvatar,
|
||||
uploadSpaceIcon,
|
||||
|
||||
@@ -15,6 +15,15 @@ export interface IAttachment {
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface IPageAttachment extends IAttachment {
|
||||
url: string;
|
||||
creator: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export enum AvatarIconType {
|
||||
AVATAR = "avatar",
|
||||
SPACE_ICON = "space-icon",
|
||||
|
||||
@@ -103,6 +103,7 @@ const CommentEditor = forwardRef(
|
||||
},
|
||||
content: defaultContent,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus: (autofocus && "end") || false,
|
||||
|
||||
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content as any}
|
||||
/>
|
||||
|
||||
@@ -249,6 +249,7 @@ function CollabPageEditor({
|
||||
{
|
||||
extensions,
|
||||
editable,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
@@ -484,6 +485,7 @@ function StaticPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={mainExtensions}
|
||||
content={content}
|
||||
editorProps={{
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={titleExtensions}
|
||||
content={title}
|
||||
></EditorProvider>
|
||||
@@ -93,6 +94,7 @@ export default function ReadonlyPageEditor({
|
||||
<EditorProvider
|
||||
editable={false}
|
||||
immediatelyRender={true}
|
||||
textDirection="auto"
|
||||
extensions={extensions}
|
||||
content={content}
|
||||
onCreate={({ editor }) => {
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
|
||||
@@ -86,6 +86,7 @@ export function TitleEditor({
|
||||
},
|
||||
editable: editable,
|
||||
content: title,
|
||||
textDirection: "auto",
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
|
||||
@@ -31,6 +31,7 @@ export function HistoryEditor({
|
||||
const editor = useEditor({
|
||||
extensions: mainExtensions,
|
||||
editable: false,
|
||||
textDirection: "auto",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconList,
|
||||
IconMarkdown,
|
||||
IconMessage,
|
||||
IconPaperclip,
|
||||
IconPrinter,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
import PageAttachmentsModal from "@/features/attachments/components/page-attachments-modal.tsx";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { PageShareModal } from "@/ee/page-permission";
|
||||
import {
|
||||
@@ -157,6 +159,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
verificationOpened,
|
||||
{ open: openVerificationModal, close: closeVerificationModal },
|
||||
] = useDisclosure(false);
|
||||
const [
|
||||
attachmentsOpened,
|
||||
{ open: openAttachmentsModal, close: closeAttachmentsModal },
|
||||
] = useDisclosure(false);
|
||||
const [pageEditor] = useAtom(pageEditorAtom);
|
||||
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
|
||||
const favoriteIds = useFavoriteIds("page", page?.spaceId);
|
||||
@@ -293,6 +299,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!page?.isBase && (
|
||||
<Menu.Item
|
||||
leftSection={<IconPaperclip size={16} />}
|
||||
onClick={openAttachmentsModal}
|
||||
>
|
||||
{t("Attachments")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{!readOnly && !page?.isBase && (
|
||||
<PageVerificationMenuItem
|
||||
pageId={page?.id}
|
||||
@@ -395,6 +410,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
opened={verificationOpened}
|
||||
onClose={closeVerificationModal}
|
||||
/>
|
||||
|
||||
<PageAttachmentsModal
|
||||
pageId={page.id}
|
||||
open={attachmentsOpened}
|
||||
onClose={closeAttachmentsModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
|
||||
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
|
||||
import { getAiVectorDriver } from "@/lib/config.ts";
|
||||
|
||||
interface SearchSpotlightProps {
|
||||
spaceId?: string;
|
||||
}
|
||||
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const { t } = useTranslation();
|
||||
const hasAiFeature = useHasFeature(Feature.AI);
|
||||
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
|
||||
@@ -96,6 +101,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
/>
|
||||
));
|
||||
|
||||
const handleSpotlightOpen = () => {
|
||||
if (
|
||||
workspace?.settings?.ai?.search === true &&
|
||||
getAiVectorDriver() === "turbopuffer"
|
||||
) {
|
||||
hintVectorCache();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersChange = (newFilters: any) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
@@ -115,6 +129,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
<Spotlight.Root
|
||||
size="xl"
|
||||
maxHeight={600}
|
||||
onSpotlightOpen={handleSpotlightOpen}
|
||||
store={searchSpotlightStore}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
|
||||
width?: number;
|
||||
opened?: boolean;
|
||||
clearable?: boolean;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||
@@ -41,6 +42,7 @@ export function SpaceSelect({
|
||||
width,
|
||||
opened,
|
||||
clearable,
|
||||
withinPortal = true,
|
||||
}: SpaceSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
@@ -50,9 +52,13 @@ export function SpaceSelect({
|
||||
limit: 50,
|
||||
});
|
||||
const [data, setData] = useState([]);
|
||||
const fetchedSpaces = useRef(new Map<string, ISpace>());
|
||||
|
||||
useEffect(() => {
|
||||
if (spaces) {
|
||||
spaces.items.forEach((space: ISpace) =>
|
||||
fetchedSpaces.current.set(space.slug, space),
|
||||
);
|
||||
const spaceData = spaces?.items
|
||||
.filter((space: ISpace) => space.slug !== value)
|
||||
.map((space: ISpace) => {
|
||||
@@ -83,14 +89,19 @@ export function SpaceSelect({
|
||||
onSearchChange={setSearchValue}
|
||||
clearable={clearable}
|
||||
variant="filled"
|
||||
onChange={(slug) =>
|
||||
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||
}
|
||||
onChange={(slug) => {
|
||||
// options accumulate across fetches; resolve against everything
|
||||
// fetched, not just the latest query result
|
||||
const space = slug && fetchedSpaces.current.get(slug);
|
||||
if (space) {
|
||||
onChange(space);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
dropdownOpened={opened}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ export function SwitchSpace({
|
||||
onChange={(space) => handleSelect(space.slug)}
|
||||
width={300}
|
||||
opened={true}
|
||||
withinPortal={false}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
|
||||
return castToBoolean(getConfigValue("CLOUD"));
|
||||
}
|
||||
|
||||
export function getAiVectorDriver(): string {
|
||||
return getConfigValue("AI_VECTOR_DRIVER");
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
AI_VECTOR_DRIVER,
|
||||
} = loadEnv(mode, envPath, "");
|
||||
|
||||
return {
|
||||
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
|
||||
BILLING_TRIAL_DAYS,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_KEY,
|
||||
AI_VECTOR_DRIVER,
|
||||
},
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"@nestjs/websockets": "11.1.28",
|
||||
"@node-saml/passport-saml": "5.1.0",
|
||||
"@socket.io/redis-adapter": "8.3.0",
|
||||
"@turbopuffer/turbopuffer": "^2.8.0",
|
||||
"ai": "6.0.134",
|
||||
"ai-sdk-ollama": "3.8.1",
|
||||
"bcrypt": "6.0.0",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||
import { EncryptionModule } from './integrations/encryption/encryption.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -53,6 +54,7 @@ try {
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
EncryptionModule,
|
||||
RedisModule.forRootAsync({
|
||||
useClass: RedisConfigService,
|
||||
}),
|
||||
|
||||
@@ -50,8 +50,14 @@ import {
|
||||
Footnote,
|
||||
FootnoteReference,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import {
|
||||
extensions as coreExtensions,
|
||||
generateText,
|
||||
getSchema,
|
||||
JSONContent,
|
||||
} from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
import { collapseBlankLines } from '../common/helpers';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
||||
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
||||
@@ -61,6 +67,7 @@ import * as Y from 'yjs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
export const tiptapExtensions = [
|
||||
coreExtensions.TextDirection.configure({ direction: 'auto' }),
|
||||
StarterKit.configure({
|
||||
document: false,
|
||||
codeBlock: false,
|
||||
@@ -140,7 +147,7 @@ export function htmlToJson(html: string) {
|
||||
}
|
||||
|
||||
export function jsonToText(tiptapJson: JSONContent) {
|
||||
return generateText(tiptapJson, tiptapExtensions);
|
||||
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
|
||||
}
|
||||
|
||||
export function jsonToNode(tiptapJson: JSONContent) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './utils';
|
||||
export * from './text.utils';
|
||||
export * from './nanoid.utils';
|
||||
export * from './file.helper';
|
||||
export * from './constants';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { collapseBlankLines } from './text.utils';
|
||||
|
||||
describe('collapseBlankLines', () => {
|
||||
it.each([
|
||||
['a\n\n\n\nb', 'a\n\nb'],
|
||||
['a\n\nb', 'a\n\nb'],
|
||||
['a\nb', 'a\nb'],
|
||||
['\n\n\n\na\n\n\n', '\n\na\n\n'],
|
||||
['no newlines', 'no newlines'],
|
||||
['', ''],
|
||||
])('collapses %j to %j', (input, expected) => {
|
||||
expect(collapseBlankLines(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function collapseBlankLines(text: string): string {
|
||||
return text.replace(/\n{2,}/g, '\n\n');
|
||||
}
|
||||
@@ -53,8 +53,14 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
|
||||
import * as path from 'path';
|
||||
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
|
||||
import {
|
||||
AttachmentInfoDto,
|
||||
PageIdDto,
|
||||
RemoveIconDto,
|
||||
} from './dto/attachment.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PageAccessService } from '../page/page-access/page-access.service';
|
||||
import { DomainService } from '../../integrations/environment/domain.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
@@ -75,6 +81,7 @@ export class AttachmentController {
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly pageAccessService: PageAccessService,
|
||||
private readonly domainService: DomainService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
@@ -151,7 +158,10 @@ export class AttachmentController {
|
||||
},
|
||||
});
|
||||
|
||||
return res.send(fileResponse);
|
||||
return res.send({
|
||||
...fileResponse,
|
||||
url: this.buildFileUrl(workspace, fileResponse),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||
@@ -411,7 +421,37 @@ export class AttachmentController {
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
return attachment;
|
||||
return { ...attachment, url: this.buildFileUrl(workspace, attachment) };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/attachments')
|
||||
async getPageAttachments(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page || page.workspaceId !== workspace.id) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const result = await this.attachmentRepo.findPageAttachments(
|
||||
page.id,
|
||||
pagination,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
items: result.items.map((attachment) => ({
|
||||
...attachment,
|
||||
url: this.buildFileUrl(workspace, attachment),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -465,6 +505,10 @@ export class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
private buildFileUrl(workspace: Workspace, attachment: Attachment): string {
|
||||
return `${this.domainService.getUrl(workspace.hostname)}/api/files/${attachment.id}/${encodeURIComponent(attachment.fileName)}`;
|
||||
}
|
||||
|
||||
private async sendFileResponse(
|
||||
req: FastifyRequest,
|
||||
res: FastifyReply,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { AttachmentType } from '../attachment.constants';
|
||||
|
||||
export class AttachmentInfoDto {
|
||||
@@ -7,6 +14,12 @@ export class AttachmentInfoDto {
|
||||
attachmentId: string;
|
||||
}
|
||||
|
||||
export class PageIdDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export class RemoveIconDto {
|
||||
@IsEnum(AttachmentType)
|
||||
@IsIn([
|
||||
|
||||
@@ -496,10 +496,21 @@ export class PageService {
|
||||
},
|
||||
);
|
||||
|
||||
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
|
||||
pageIds: pageIdsToMove,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
});
|
||||
await this.aiQueue.add(
|
||||
QueueJob.PAGE_MOVED_TO_SPACE,
|
||||
{
|
||||
pageIds: pageIdsToMove,
|
||||
spaceId,
|
||||
workspaceId: rootPage.workspaceId,
|
||||
},
|
||||
{
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'fixed',
|
||||
delay: 2 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
|
||||
|
||||
export class SpaceEvent {
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -22,12 +23,12 @@ export class SpaceListener {
|
||||
|
||||
@OnEvent(EventName.SPACE_DELETED)
|
||||
async handleSpaceDeleted(event: SpaceEvent) {
|
||||
const { spaceId } = event;
|
||||
const { spaceId, workspaceId } = event;
|
||||
if (this.isTypesense()) {
|
||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||
}
|
||||
|
||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
|
||||
}
|
||||
|
||||
isTypesense(): boolean {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { ExpressionBuilder, sql } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import {
|
||||
@@ -8,6 +11,8 @@ import {
|
||||
UpdatableAttachment,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { AttachmentType } from '../../../core/attachment/attachment.constants';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentRepo {
|
||||
@@ -89,6 +94,41 @@ export class AttachmentRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findPageAttachments(pageId: string, pagination: PaginationOptions) {
|
||||
let query = this.db
|
||||
.selectFrom('attachments')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('type', '=', AttachmentType.File)
|
||||
.where('deletedAt', 'is', null);
|
||||
|
||||
if (pagination.query) {
|
||||
query = query.where(
|
||||
sql`f_unaccent(file_name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
);
|
||||
}
|
||||
|
||||
return executeWithCursorPagination(query, {
|
||||
perPage: pagination.limit,
|
||||
cursor: pagination.cursor,
|
||||
beforeCursor: pagination.beforeCursor,
|
||||
fields: [{ expression: 'id', direction: 'desc' }],
|
||||
parseCursor: (cursor) => ({ id: cursor.id }),
|
||||
});
|
||||
}
|
||||
|
||||
withCreator(eb: ExpressionBuilder<DB, 'attachments'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'attachments.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findByIds(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
|
||||
@@ -230,6 +230,7 @@ export class SpaceRepo {
|
||||
|
||||
this.eventEmitter.emit(EventName.SPACE_DELETED, {
|
||||
spaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,24 @@ export class WorkspaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateAiEmbeddingFingerprint(
|
||||
workspaceId: string,
|
||||
fingerprint: { driver: string; model: string; dimensions: number },
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateSharingSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 41acc8af7d...660418ac2c
@@ -0,0 +1,13 @@
|
||||
export class UnableToInitialize extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Unable to initialize the encryption service: ${message}`);
|
||||
this.name = 'UnableToInitialize';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnableToDecrypt extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`Unable to decrypt the ciphertext: ${reason}`);
|
||||
this.name = 'UnableToDecrypt';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EncryptionService } from './encryption.service';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
|
||||
|
||||
const buildService = (appSecret: string | undefined) => {
|
||||
const env = { getAppSecret: () => appSecret } as EnvironmentService;
|
||||
return new EncryptionService(env);
|
||||
};
|
||||
|
||||
const decodeEnvelope = (encrypted: string) =>
|
||||
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
};
|
||||
|
||||
const encodeEnvelope = (envelope: {
|
||||
iv: string;
|
||||
authTag: string;
|
||||
cipherText: string;
|
||||
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
let service: EncryptionService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EncryptionService,
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { getAppSecret: () => APP_SECRET },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EncryptionService>(EncryptionService);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('compiles via Nest DI', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws UnableToInitialize when APP_SECRET is missing', () => {
|
||||
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
|
||||
expect(() => buildService('')).toThrow(UnableToInitialize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt + decrypt round-trip', () => {
|
||||
it('decrypts back to the original plaintext', () => {
|
||||
const plaintext = 'hello world';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const encrypted = service.encrypt('');
|
||||
expect(service.decrypt(encrypted)).toBe('');
|
||||
});
|
||||
|
||||
it('handles unicode (multi-byte UTF-8)', () => {
|
||||
const plaintext = 'héllo 🔐 世界';
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('handles long plaintext (>1 block)', () => {
|
||||
const plaintext = 'a'.repeat(10_000);
|
||||
const encrypted = service.encrypt(plaintext);
|
||||
expect(service.decrypt(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
|
||||
const plaintext = 'same input';
|
||||
const a = service.encrypt(plaintext);
|
||||
const b = service.encrypt(plaintext);
|
||||
expect(a).not.toBe(b);
|
||||
expect(service.decrypt(a)).toBe(plaintext);
|
||||
expect(service.decrypt(b)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-key isolation', () => {
|
||||
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
|
||||
const other = buildService('totally-different-secret-value-9876543210');
|
||||
const encrypted = service.encrypt('secret');
|
||||
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tamper detection', () => {
|
||||
it('rejects modified ciphertext', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
|
||||
tamperedCipher[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
cipherText: tamperedCipher.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedTag = Buffer.from(env.authTag, 'base64');
|
||||
tamperedTag[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
authTag: tamperedTag.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects modified IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const tamperedIV = Buffer.from(env.iv, 'base64');
|
||||
tamperedIV[0] ^= 0x01;
|
||||
const tampered = encodeEnvelope({
|
||||
...env,
|
||||
iv: tamperedIV.toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payloads', () => {
|
||||
it('rejects non-base64 garbage', () => {
|
||||
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
|
||||
UnableToDecrypt,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects base64 of non-JSON', () => {
|
||||
const garbage = Buffer.from('not json at all').toString('base64');
|
||||
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects JSON missing required fields', () => {
|
||||
const partial = encodeEnvelope({
|
||||
iv: Buffer.alloc(12).toString('base64'),
|
||||
authTag: Buffer.alloc(16).toString('base64'),
|
||||
} as never);
|
||||
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length IV', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
iv: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
|
||||
it('rejects wrong-length auth tag', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
const bad = encodeEnvelope({
|
||||
...env,
|
||||
authTag: Buffer.alloc(8).toString('base64'),
|
||||
});
|
||||
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envelope format', () => {
|
||||
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
|
||||
const encrypted = service.encrypt('hello');
|
||||
const env = decodeEnvelope(encrypted);
|
||||
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
|
||||
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
|
||||
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// https://github.com/nhedger/nestjs-encryption - MIT
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_DOMAIN = 'docmost:encryption:v1';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
type AEADPayload<TFormat = string | Buffer> = {
|
||||
iv: TFormat;
|
||||
authTag: TFormat;
|
||||
cipherText: TFormat;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor(environmentService: EnvironmentService) {
|
||||
const appSecret = environmentService.getAppSecret();
|
||||
if (!appSecret) {
|
||||
throw new UnableToInitialize('APP_SECRET is not set.');
|
||||
}
|
||||
this.key = createHash('sha256')
|
||||
.update(KEY_DOMAIN)
|
||||
.update(appSecret)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public encrypt(plaintext: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, this.key, iv);
|
||||
const cipherText = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
const aead: AEADPayload<string> = {
|
||||
iv: iv.toString('base64'),
|
||||
authTag: authTag.toString('base64'),
|
||||
cipherText: cipherText.toString('base64'),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(aead)).toString('base64');
|
||||
}
|
||||
|
||||
public decrypt(encrypted: string): string {
|
||||
try {
|
||||
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
|
||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(cipherText),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (e: unknown) {
|
||||
throw new UnableToDecrypt((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
|
||||
const payload = Buffer.from(encodedPayload, 'base64');
|
||||
|
||||
let deserializedPkg: Record<string, unknown>;
|
||||
try {
|
||||
deserializedPkg = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
throw new Error('The decoded AEAD payload is not a valid JSON string.');
|
||||
}
|
||||
|
||||
for (const field of ['iv', 'authTag', 'cipherText']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
|
||||
throw new Error(`The AEAD payload is missing the ${field} field.`);
|
||||
}
|
||||
}
|
||||
|
||||
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
|
||||
if (iv.length !== IV_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
|
||||
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||
throw new Error(
|
||||
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cipherText = Buffer.from(
|
||||
deserializedPkg.cipherText as string,
|
||||
'base64',
|
||||
);
|
||||
|
||||
return { iv, authTag, cipherText };
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,31 @@ export class EnvironmentService {
|
||||
return val === 'true';
|
||||
}
|
||||
|
||||
getAiVectorDriver(): string {
|
||||
return this.configService
|
||||
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
getTurbopufferApiKey(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_API_KEY');
|
||||
}
|
||||
|
||||
getTurbopufferRegion(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_REGION');
|
||||
}
|
||||
|
||||
getTurbopufferBaseUrl(): string {
|
||||
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
|
||||
}
|
||||
|
||||
getTurbopufferNamespacePrefix(): string {
|
||||
return this.configService.get<string>(
|
||||
'TURBOPUFFER_NAMESPACE_PREFIX',
|
||||
'docmost',
|
||||
);
|
||||
}
|
||||
|
||||
getOpenAiApiKey(): string {
|
||||
return this.configService.get<string>('OPENAI_API_KEY');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
validateSync,
|
||||
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
AI_DRIVER: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
|
||||
@IsIn(['pgvector', 'turbopuffer'])
|
||||
@IsString()
|
||||
AI_VECTOR_DRIVER: string;
|
||||
|
||||
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
TURBOPUFFER_API_KEY: string;
|
||||
|
||||
@ValidateIf(
|
||||
(obj) =>
|
||||
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
|
||||
)
|
||||
@IsNotEmpty({
|
||||
message:
|
||||
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
|
||||
})
|
||||
@IsString()
|
||||
TURBOPUFFER_REGION: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
|
||||
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
|
||||
TURBOPUFFER_BASE_URL: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
|
||||
message:
|
||||
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
|
||||
})
|
||||
TURBOPUFFER_NAMESPACE_PREFIX: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
AI_EMBEDDING_MODEL: string;
|
||||
|
||||
@@ -61,6 +61,7 @@ export enum QueueJob {
|
||||
WORKSPACE_DELETED = 'workspace-deleted',
|
||||
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
|
||||
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
|
||||
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
|
||||
|
||||
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
|
||||
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
|
||||
|
||||
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
|
||||
: undefined,
|
||||
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
||||
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
||||
AI_VECTOR_DRIVER:
|
||||
this.environmentService.getAiVectorDriver() === 'turbopuffer'
|
||||
? 'turbopuffer'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
||||
|
||||
@@ -192,6 +192,7 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||
// No usable static export representation: skip without failing.
|
||||
subpages() {},
|
||||
transclusionReference() {},
|
||||
base() {},
|
||||
};
|
||||
|
||||
export const defaultMarks: MarkSerializer = {
|
||||
|
||||
Generated
+17
@@ -593,6 +593,9 @@ importers:
|
||||
'@socket.io/redis-adapter':
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0(socket.io-adapter@2.5.4(supports-color@10.2.2))(supports-color@10.2.2)
|
||||
'@turbopuffer/turbopuffer':
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0
|
||||
ai:
|
||||
specifier: 6.0.134
|
||||
version: 6.0.134(zod@4.3.6)
|
||||
@@ -4382,6 +4385,9 @@ packages:
|
||||
'@tsconfig/node16@1.0.4':
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
|
||||
'@turbopuffer/turbopuffer@2.8.0':
|
||||
resolution: {integrity: sha512-lurmVByLynuWtPtVl7SBEJ0bNakb8bs4KsafKzeLWnXA6BsQiGS9obT/7ExQVb9/yecyRn/grj8VgwUzi2xMyg==}
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
|
||||
@@ -5698,6 +5704,7 @@ packages:
|
||||
cron-parser@4.9.0:
|
||||
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
deprecated: v4 is no longer maintained, upgrade to v5
|
||||
|
||||
cron@4.4.0:
|
||||
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
||||
@@ -8168,6 +8175,9 @@ packages:
|
||||
pako@2.0.3:
|
||||
resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==}
|
||||
|
||||
pako@2.2.0:
|
||||
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -14341,6 +14351,11 @@ snapshots:
|
||||
|
||||
'@tsconfig/node16@1.0.4': {}
|
||||
|
||||
'@turbopuffer/turbopuffer@2.8.0':
|
||||
dependencies:
|
||||
pako: 2.2.0
|
||||
undici: 7.29.0
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -18749,6 +18764,8 @@ snapshots:
|
||||
|
||||
pako@2.0.3: {}
|
||||
|
||||
pako@2.2.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
||||
Reference in New Issue
Block a user