Compare commits

..

4 Commits

Author SHA1 Message Date
Philipinho 6f92ecedf6 feat: add HTTP range request support for file serving 2026-02-04 10:29:38 -08:00
Philipinho 7e16c39f9a fix safari upload bug 2026-02-04 09:40:40 -08:00
Philipinho 03714a8bec fix hasFocus bug 2026-02-04 09:20:48 -08:00
Philipinho 3003befe07 use widely available arrayBuffer
* stream fails in safari
2026-02-04 09:08:23 -08:00
33 changed files with 178 additions and 574 deletions
@@ -407,21 +407,6 @@
"Share deleted successfully": "Share deleted successfully", "Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found", "Share not found": "Share not found",
"Failed to share page": "Failed to share page", "Failed to share page": "Failed to share page",
"Disable public sharing": "Disable public sharing",
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Enable public sharing": "Enable public sharing",
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
"Public sharing is disabled": "Public sharing is disabled",
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
"Copy page": "Copy page", "Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.", "Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully", "Page copied successfully": "Page copied successfully",
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -1,88 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
</Group>
);
}
function DisablePublicSharingToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
const updatedWorkspace = await updateWorkspace({
disablePublicSharing: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
)
: t(
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
);
}
@@ -10,18 +10,23 @@ export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Group justify="space-between" wrap="nowrap" gap="xl"> <>
<div> <Title order={4} my="sm">
<Text size="md">{t("Enforce two-factor authentication")}</Text> MFA
<Text size="sm" c="dimmed"> </Title>
{t( <Group justify="space-between" wrap="nowrap" gap="xl">
"Once enforced, all members must enable two-factor authentication to access the workspace.", <div>
)} <Text size="md">{t("Enforce two-factor authentication")}</Text>
</Text> <Text size="sm" c="dimmed">
</div> {t(
"Once enforced, all members must enable two-factor authentication to access the workspace.",
)}
</Text>
</div>
<EnforceMfaToggle /> <EnforceMfaToggle />
</Group> </Group>
</>
); );
} }
@@ -1,84 +0,0 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
};
export default function SpacePublicSharingToggle({
space,
}: SpacePublicSharingToggleProps) {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const applyChange = async (value: boolean) => {
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
disablePublicSharing: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
modals.openConfirmModal({
title: value ? t("Disable public sharing") : t("Enable public sharing"),
children: (
<Text size="sm">
{value
? t(
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
)
: t(
"Are you sure you want to enable public sharing for this space?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
confirmProps: value ? { color: "red" } : {},
onConfirm: () => applyChange(value),
});
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{workspaceDisabled
? t("Public sharing is disabled at the workspace level")
: t("Prevent pages in this space from being shared publicly.")}
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
</Group>
);
}
+10 -26
View File
@@ -9,16 +9,15 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
import EnforceSso from "@/ee/security/components/enforce-sso.tsx"; import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess(); const { hasLicenseKey } = useLicense();
const isCloudEE = useIsCloudEE(); const { isBusiness } = usePlan();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -31,41 +30,26 @@ export default function Security() {
</Helmet> </Helmet>
<SettingsTitle title={t("Security")} /> <SettingsTitle title={t("Security")} />
<EnforceMfa /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && ( <EnforceMfa />
<>
<DisablePublicSharing /> <Divider my="lg" />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{hasEnterpriseAccess && ( {(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
</> </>
)} ) : null}
<SsoProviderList /> <SsoProviderList />
</> </>
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { for (const file of input.files) {
@@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
} }
} }
// Reset the input value to allow uploading the same file again if needed input.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -157,7 +157,9 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
titleEditor?.commands.focus("end"); // guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
@@ -26,9 +26,6 @@ import { getAppUrl, isCloud } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css"; import classes from "@/features/share/components/share.module.css";
import useTrial from "@/ee/hooks/use-trial.tsx"; import useTrial from "@/ee/hooks/use-trial.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
interface ShareModalProps { interface ShareModalProps {
readOnly: boolean; readOnly: boolean;
@@ -43,12 +40,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { data: share } = useShareForPageQuery(pageId); const { data: share } = useShareForPageQuery(pageId);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { isTrial } = useTrial(); const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled =
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation(); const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation(); const deleteShareMutation = useDeleteShareMutation();
@@ -173,20 +164,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{t("Upgrade Plan")} {t("Upgrade Plan")}
</Button> </Button>
</> </>
) : sharingDisabled ? (
<>
<Group justify="center" mb="sm">
<IconLock size={20} stroke={1.5} />
</Group>
<Text size="sm" ta="center" fw={500} mb="xs">
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</>
) : isDescendantShared ? ( ) : isDescendantShared ? (
<> <>
<Text size="sm">{t("Inherits public sharing from")}</Text> <Text size="sm">{t("Inherits public sharing from")}</Text>
@@ -18,8 +18,6 @@ import {
ResponsiveSettingsControl, ResponsiveSettingsControl,
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -28,8 +26,6 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const hasEnterpriseAccess = useEnterpriseAccess();
const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false); const [isIconUploading, setIsIconUploading] = useState(false);
@@ -81,6 +77,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
fallbackName={space.name} fallbackName={space.name}
size={"60px"} size={"60px"}
variant="filled" variant="filled"
type={AvatarIconType.SPACE_ICON} type={AvatarIconType.SPACE_ICON}
onUpload={handleIconUpload} onUpload={handleIconUpload}
onRemove={handleIconRemove} onRemove={handleIconRemove}
@@ -91,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
@@ -5,14 +5,6 @@ import {
} from "@/features/space/permissions/permissions.type.ts"; } from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts"; import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpaceSharingSettings {
disabled?: boolean;
}
export interface ISpaceSettings {
sharing?: ISpaceSharingSettings;
}
export interface ISpace { export interface ISpace {
id: string; id: string;
name: string; name: string;
@@ -26,9 +18,6 @@ export interface ISpace {
memberCount?: number; memberCount?: number;
spaceId?: string; spaceId?: string;
membership?: IMembership; membership?: IMembership;
settings?: ISpaceSettings;
// for updates
disablePublicSharing?: boolean;
} }
interface IMembership { interface IMembership {
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
await api.post("/workspace/members/delete", data); await api.post("/workspace/members/delete", data);
} }
export async function updateWorkspace(data: Partial<IWorkspace>) { export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;
} }
@@ -66,9 +66,7 @@ export async function createInvitation(data: ICreateInvite) {
return req.data; return req.data;
} }
export async function acceptInvitation( export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
data: IAcceptInvite,
): Promise<{ requiresLogin?: boolean }> {
const req = await api.post("/workspace/invites/accept", data); const req = await api.post("/workspace/invites/accept", data);
return req.data; return req.data;
} }
@@ -110,3 +108,4 @@ export async function getAppVersion(): Promise<IVersion> {
const req = await api.post("/version"); const req = await api.post("/version");
return req.data; return req.data;
} }
@@ -22,23 +22,16 @@ export interface IWorkspace {
plan?: string; plan?: string;
hasLicenseKey?: boolean; hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean;
disablePublicSharing?: boolean;
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings; ai?: IWorkspaceAiSettings;
sharing?: IWorkspaceSharingSettings;
} }
export interface IWorkspaceAiSettings { export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
} }
export interface IWorkspaceSharingSettings {
disabled?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
+1 -1
View File
@@ -92,9 +92,9 @@
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"postgres": "^3.4.8",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postgres": "^3.4.8",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -17,13 +17,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import { StorageService } from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -181,22 +182,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'private');
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -205,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName') @Get('/files/public/:fileId/:fileName')
async getPublicFile( async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@@ -243,22 +230,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'public');
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -433,4 +405,69 @@ export class AttachmentController {
return; return;
} }
} }
private async sendFileResponse(
req: FastifyRequest,
res: FastifyReply,
attachment: Attachment,
cacheScope: 'private' | 'public',
) {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (rangeHeader && fileSize) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2]
? Math.min(parseInt(match[2], 10), fileSize - 1)
: fileSize - 1;
if (start >= fileSize || start > end) {
res.status(416);
res.header('Content-Range', `bytes */${fileSize}`);
return res.send();
}
const fileStream = await this.storageService.readRangeStream(
attachment.filePath,
{ start, end },
);
res.status(206);
res.headers({
'Content-Type': attachment.mimeType,
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Length': end - start + 1,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
return res.send(fileStream);
}
}
const fileStream = await this.storageService.readStream(
attachment.filePath,
);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': `${cacheScope}, max-age=3600`,
});
if (fileSize) {
res.header('Content-Length', fileSize);
}
return res.send(fileStream);
}
} }
+2 -41
View File
@@ -64,18 +64,8 @@ export class ShareController {
throw new BadRequestException(); throw new BadRequestException();
} }
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
shareData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Shared page not found');
}
return { return {
...shareData, ...(await this.shareService.getSharedPage(dto, workspace.id)),
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -96,14 +86,6 @@ export class ShareController {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
share.workspaceId,
share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return share; return share;
} }
@@ -145,14 +127,6 @@ export class ShareController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
page.spaceId,
);
if (!sharingAllowed) {
throw new ForbiddenException('Public sharing is disabled');
}
return this.shareService.createShare({ return this.shareService.createShare({
page, page,
authUserId: user.id, authUserId: user.id,
@@ -202,21 +176,8 @@ export class ShareController {
@Body() dto: ShareIdDto, @Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const treeData = await this.shareService.getShareTree(
dto.shareId,
workspace.id,
);
const sharingAllowed = await this.shareService.isSharingAllowed(
workspace.id,
treeData.share.spaceId,
);
if (!sharingAllowed) {
throw new NotFoundException('Share not found');
}
return { return {
...treeData, ...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
hasLicenseKey: hasLicenseOrEE({ hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey, licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(), isCloud: this.environmentService.isCloud(),
@@ -264,31 +264,6 @@ export class ShareService {
return ancestor; return ancestor;
} }
async isSharingAllowed(
workspaceId: string,
spaceId: string,
): Promise<boolean> {
const result = await this.db
.selectFrom('workspaces')
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
.select([
'workspaces.settings as workspaceSettings',
'spaces.settings as spaceSettings',
])
.where('workspaces.id', '=', workspaceId)
.where('spaces.id', '=', spaceId)
.executeTakeFirst();
if (!result) return false;
const workspaceDisabled =
(result.workspaceSettings as any)?.sharing?.disabled === true;
const spaceDisabled =
(result.spaceSettings as any)?.sharing?.disabled === true;
return !workspaceDisabled && !spaceDisabled;
}
async updatePublicAttachments(page: Page): Promise<any> { async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content); const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson); const attachmentIds = getAttachmentIds(prosemirrorJson);
@@ -1,14 +1,10 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto'; import { CreateSpaceDto } from './create-space.dto';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
} }
@@ -1,6 +1,5 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -18,18 +17,12 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
@Injectable() @Injectable()
export class SpaceService { export class SpaceService {
constructor( constructor(
private spaceRepo: SpaceRepo, private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService, private spaceMemberService: SpaceMemberService,
private shareRepo: ShareRepo,
private workspaceRepo: WorkspaceRepo,
private licenseCheckService: LicenseCheckService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
) {} ) {}
@@ -112,31 +105,6 @@ export class SpaceService {
} }
} }
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.spaceRepo.updateSharingSettings(
updateSpaceDto.spaceId,
workspaceId,
'disabled',
updateSpaceDto.disablePublicSharing,
);
if (updateSpaceDto.disablePublicSharing) {
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
}
}
return await this.spaceRepo.updateSpace( return await this.spaceRepo.updateSpace(
{ {
name: updateSpaceDto.name, name: updateSpaceDto.name,
@@ -30,8 +30,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
generativeAi: boolean; generativeAi: boolean;
@IsOptional()
@IsBoolean()
disablePublicSharing: boolean;
} }
@@ -5,7 +5,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto'; import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto'; import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service'; import { SpaceService } from '../../space/services/space.service';
@@ -34,7 +33,6 @@ import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -49,8 +47,6 @@ export class WorkspaceService {
private userRepo: UserRepo, private userRepo: UserRepo,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private domainService: DomainService, private domainService: DomainService,
private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -362,32 +358,6 @@ export class WorkspaceService {
delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.generativeAi;
} }
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
});
if (
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
await this.workspaceRepo.updateSharingSettings(
workspaceId,
'disabled',
updateWorkspaceDto.disablePublicSharing,
);
if (updateWorkspaceDto.disablePublicSharing) {
await this.shareRepo.deleteByWorkspaceId(workspaceId);
}
delete updateWorkspaceDto.disablePublicSharing;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -1,9 +0,0 @@
import { Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('spaces').dropColumn('settings').execute();
}
@@ -136,20 +136,6 @@ export class ShareRepo {
await query.execute(); await query.execute();
} }
async deleteBySpaceId(spaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('spaceId', '=', spaceId)
.execute();
}
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
await this.db
.deleteFrom('shares')
.where('workspaceId', '=', workspaceId)
.execute();
}
async getShares(userId: string, pagination: PaginationOptions) { async getShares(userId: string, pagination: PaginationOptions) {
const query = this.db const query = this.db
.selectFrom('shares') .selectFrom('shares')
@@ -89,26 +89,6 @@ export class SpaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async updateSharingSettings(
spaceId: string,
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('spaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.returningAll()
.executeTakeFirst();
}
async insertSpace( async insertSpace(
insertableSpace: InsertableSpace, insertableSpace: InsertableSpace,
trx?: KyselyTransaction, trx?: KyselyTransaction,
@@ -193,22 +193,4 @@ export class WorkspaceRepo {
.returning(this.baseFields) .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
async updateSharingSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
} }
-1
View File
@@ -273,7 +273,6 @@ export interface Spaces {
id: Generated<string>; id: Generated<string>;
logo: string | null; logo: string | null;
name: string | null; name: string | null;
settings: Json | null;
slug: string; slug: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
visibility: Generated<string>; visibility: Generated<string>;
@@ -4,7 +4,6 @@ import { ConfigModule } from '@nestjs/config';
import { validate } from './environment.validation'; import { validate } from './environment.validation';
import { envPath } from '../../common/helpers'; import { envPath } from '../../common/helpers';
import { DomainService } from './domain.service'; import { DomainService } from './domain.service';
import { LicenseCheckService } from './license-check.service';
@Global() @Global()
@Module({ @Module({
@@ -16,7 +15,7 @@ import { LicenseCheckService } from './license-check.service';
validate, validate,
}), }),
], ],
providers: [EnvironmentService, DomainService, LicenseCheckService], providers: [EnvironmentService, DomainService],
exports: [EnvironmentService, DomainService, LicenseCheckService], exports: [EnvironmentService, DomainService],
}) })
export class EnvironmentModule {} export class EnvironmentModule {}
@@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EnvironmentService } from './environment.service';
@Injectable()
export class LicenseCheckService {
constructor(
private moduleRef: ModuleRef,
private environmentService: EnvironmentService,
) {}
isValidEELicense(licenseKey: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.isValidEELicense(licenseKey);
} catch {
return false;
}
}
}
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
return createReadStream(this._fullPath(filePath), {
start: range.start,
end: range.end,
});
} catch (err) {
throw new Error(`Failed to read file: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
return await fs.pathExists(this._fullPath(filePath)); return await fs.pathExists(this._fullPath(filePath));
@@ -130,6 +130,25 @@ export class S3Driver implements StorageDriver {
} }
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
try {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: filePath,
Range: `bytes=${range.start}-${range.end}`,
});
const response = await this.s3Client.send(command);
return response.Body as Readable;
} catch (err) {
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
}
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
const command = new HeadObjectCommand({ const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>; readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>; exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string; getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath); return this.storageDriver.readStream(filePath);
} }
async readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable> {
return this.storageDriver.readRangeStream(filePath, range);
}
async exists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath); return this.storageDriver.exists(filePath);
} }
@@ -1,9 +1,9 @@
import { imageDimensionsFromStream } from "image-dimensions"; import { imageDimensionsFromData } from 'image-dimensions';
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from '../media-utils';
import { IAttachment } from "../types"; import { IAttachment } from '../types';
import { generateNodeId } from "../utils"; import { generateNodeId } from '../utils';
import { Node } from "@tiptap/pm/model"; import { Node } from '@tiptap/pm/model';
import { Command } from "@tiptap/core"; import { Command } from '@tiptap/core';
const findImageNodeByPlaceholderId = ( const findImageNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false; if (result) return false;
if ( if (
node.type.name === "image" && node.type.name === 'image' &&
node.attrs.placeholder?.id === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
@@ -34,7 +34,11 @@ const handleImageUpload =
if (!validated) return; if (!validated) return;
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const imageDimensions = imageDimensionsFromData(
new Uint8Array(await file.arrayBuffer()),
);
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = imageDimensions const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height ? imageDimensions.width / imageDimensions.height