mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 09:21:09 +10:00
WIP
This commit is contained in:
@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import Security from "@/ee/security/pages/security.tsx";
|
import Security from "@/ee/security/pages/security.tsx";
|
||||||
import License from "@/ee/licence/pages/license.tsx";
|
import License from "@/ee/licence/pages/license.tsx";
|
||||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||||
import SharedPage from '@/pages/share/shared-page.tsx';
|
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -53,6 +53,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
|
<Route path={"/share/:shareId/:pageSlug"} element={<SharedPage />} />
|
||||||
|
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||||
|
|
||||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||||
import { IconFileDescription } from "@tabler/icons-react";
|
import { IconFileDescription } from "@tabler/icons-react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useLocation, useParams } from "react-router-dom";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import {
|
import {
|
||||||
buildPageUrl,
|
buildPageUrl,
|
||||||
@ -20,6 +20,9 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const isShareRoute = location.pathname.startsWith("/share");
|
||||||
|
|
||||||
const shareSlugUrl = buildSharedPageUrl({
|
const shareSlugUrl = buildSharedPageUrl({
|
||||||
shareId,
|
shareId,
|
||||||
pageSlugId: slugId,
|
pageSlugId: slugId,
|
||||||
@ -38,7 +41,9 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
<Anchor
|
<Anchor
|
||||||
component={Link}
|
component={Link}
|
||||||
fw={500}
|
fw={500}
|
||||||
to={shareId ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)}
|
to={
|
||||||
|
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label)
|
||||||
|
}
|
||||||
underline="never"
|
underline="never"
|
||||||
className={classes.pageMentionLink}
|
className={classes.pageMentionLink}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||||
|
import ShareModal from '@/features/share/components/share-modal.tsx';
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -58,6 +59,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ShareModal/>
|
||||||
|
|
||||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@ -31,5 +31,9 @@ export const buildSharedPageUrl = (opts: {
|
|||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
}): string => {
|
}): string => {
|
||||||
const { shareId, pageSlugId, pageTitle } = opts;
|
const { shareId, pageSlugId, pageTitle } = opts;
|
||||||
|
if (!shareId) {
|
||||||
|
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `/share/${shareId}/${buildPageSlug(pageSlugId, pageTitle)}`;
|
return `/share/${shareId}/${buildPageSlug(pageSlugId, pageTitle)}`;
|
||||||
};
|
};
|
||||||
|
|||||||
88
apps/client/src/features/share/components/share-modal.tsx
Normal file
88
apps/client/src/features/share/components/share-modal.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
MantineSize,
|
||||||
|
Popover,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconWorld } from "@tabler/icons-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useShareStatusQuery } from "@/features/share/queries/share-query.ts";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import CopyTextButton from "@/components/common/copy.tsx";
|
||||||
|
|
||||||
|
export default function ShareModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const { data } = useShareStatusQuery(extractPageSlugId(pageSlug));
|
||||||
|
|
||||||
|
const publicLink =
|
||||||
|
window.location.protocol +'//' + window.location.host +
|
||||||
|
"/share/" +
|
||||||
|
data?.["share"]?.["key"] +
|
||||||
|
"/" +
|
||||||
|
pageSlug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover width={350} position="bottom" withArrow shadow="md">
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
style={{ border: "none" }}
|
||||||
|
leftSection={<IconWorld size={20} stroke={1.5} />}
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Make page public")}</Text>
|
||||||
|
</div>
|
||||||
|
<ToggleShare isChecked={true}></ToggleShare>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group my="sm" grow>
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
value={publicLink}
|
||||||
|
pointer
|
||||||
|
readOnly
|
||||||
|
rightSection={<CopyTextButton text={publicLink} />}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageWidthToggleProps {
|
||||||
|
isChecked: boolean;
|
||||||
|
size?: MantineSize;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleShare({ isChecked, size, label }: PageWidthToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [checked, setChecked] = useState(isChecked);
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
setChecked(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
size={size}
|
||||||
|
label={label}
|
||||||
|
labelPosition="left"
|
||||||
|
defaultChecked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label={t("Toggle share")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
UseQueryResult,
|
|
||||||
} from "@tanstack/react-query";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -14,6 +10,7 @@ import {
|
|||||||
createShare,
|
createShare,
|
||||||
deleteShare,
|
deleteShare,
|
||||||
getShareInfo,
|
getShareInfo,
|
||||||
|
getShareStatus,
|
||||||
updateShare,
|
updateShare,
|
||||||
} from "@/features/share/services/share-service.ts";
|
} from "@/features/share/services/share-service.ts";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
@ -24,7 +21,20 @@ export function useShareQuery(
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["shares", shareInput],
|
queryKey: ["shares", shareInput],
|
||||||
queryFn: () => getShareInfo(shareInput),
|
queryFn: () => getShareInfo(shareInput),
|
||||||
enabled: !!shareInput.shareId,
|
enabled: !!shareInput.pageId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShareStatusQuery(
|
||||||
|
pageId: string,
|
||||||
|
): UseQueryResult<IPage, Error> {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["share-status", pageId],
|
||||||
|
queryFn: () => getShareStatus(pageId),
|
||||||
|
enabled: !!pageId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,21 @@ import {
|
|||||||
IShareInfoInput,
|
IShareInfoInput,
|
||||||
} from "@/features/share/types/share.types.ts";
|
} from "@/features/share/types/share.types.ts";
|
||||||
|
|
||||||
|
export async function getShares(data: ICreateShare): Promise<any> {
|
||||||
|
const req = await api.post<any>("/shares", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createShare(data: ICreateShare): Promise<any> {
|
export async function createShare(data: ICreateShare): Promise<any> {
|
||||||
const req = await api.post<any>("/shares/create", data);
|
const req = await api.post<any>("/shares/create", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getShareStatus(pageId: string): Promise<any> {
|
||||||
|
const req = await api.post<IPage>("/shares/status", { pageId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getShareInfo(
|
export async function getShareInfo(
|
||||||
shareInput: Partial<IShareInfoInput>,
|
shareInput: Partial<IShareInfoInput>,
|
||||||
): Promise<IPage> {
|
): Promise<IPage> {
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
export interface ICreateShare {
|
export interface ICreateShare {
|
||||||
slugId: string; // share slugId
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
includeSubPages?: boolean;
|
includeSubPages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IShareInfoInput {
|
export interface IShareInfoInput {
|
||||||
shareId: string;
|
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
@ -2,14 +2,14 @@ import { useParams } from "react-router-dom";
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useShareQuery } from "@/features/share/queries/share-query.ts";
|
import { useShareQuery } from "@/features/share/queries/share-query.ts";
|
||||||
import { Container } from "@mantine/core";
|
import { Affix, Button, Container } from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
|
||||||
export default function SharedPage() {
|
export default function SingleSharedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { shareId } = useParams();
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -17,7 +17,7 @@ export default function SharedPage() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useShareQuery({ shareId: shareId, pageId: extractPageSlugId(pageSlug) });
|
} = useShareQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@ -25,7 +25,7 @@ export default function SharedPage() {
|
|||||||
|
|
||||||
if (isError || !page) {
|
if (isError || !page) {
|
||||||
if ([401, 403, 404].includes(error?.["status"])) {
|
if ([401, 403, 404].includes(error?.["status"])) {
|
||||||
return <div>{t("Page not found")}</div>;
|
return <Error404 />;
|
||||||
}
|
}
|
||||||
return <div>{t("Error fetching page data.")}</div>;
|
return <div>{t("Error fetching page data.")}</div>;
|
||||||
}
|
}
|
||||||
@ -43,6 +43,10 @@ export default function SharedPage() {
|
|||||||
content={page.content}
|
content={page.content}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<Affix position={{ bottom: 20, right: 20 }}>
|
||||||
|
<Button variant="default">Powered by Docmost</Button>
|
||||||
|
</Affix>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { IsBoolean, IsString } from 'class-validator';
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateShareDto {
|
export class CreateShareDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
includeSubPages: boolean;
|
includeSubPages: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,18 @@ export class SpaceIdDto {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ShareInfoDto extends ShareIdDto {
|
export class ShareInfoDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
shareId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
}
|
||||||
// @IsOptional()
|
|
||||||
// @IsBoolean()
|
export class SharePageIdDto {
|
||||||
// includeContent: boolean;
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
@ -19,7 +20,7 @@ import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
|||||||
import { ShareService } from './share.service';
|
import { ShareService } from './share.service';
|
||||||
import { UpdateShareDto } from './dto/update-page.dto';
|
import { UpdateShareDto } from './dto/update-page.dto';
|
||||||
import { CreateShareDto } from './dto/create-share.dto';
|
import { CreateShareDto } from './dto/create-share.dto';
|
||||||
import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
|
import { ShareIdDto, ShareInfoDto, SharePageIdDto } from './dto/share.dto';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
@ -52,7 +53,32 @@ export class ShareController {
|
|||||||
@Body() dto: ShareInfoDto,
|
@Body() dto: ShareInfoDto,
|
||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
return this.shareService.getShare(dto, workspace.id);
|
if (!dto.pageId && !dto.shareId) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.shareService.getSharedPage(dto, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/status')
|
||||||
|
async getShareStatus(
|
||||||
|
@Body() dto: SharePageIdDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const page = await this.pageRepo.findById(dto.pageId);
|
||||||
|
|
||||||
|
if (!page || workspace.id !== page.workspaceId) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.shareService.getShareStatus(page.id, workspace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -74,10 +100,10 @@ export class ShareController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.shareService.createShare({
|
return this.shareService.createShare({
|
||||||
pageId: page.id,
|
page,
|
||||||
authUserId: user.id,
|
authUserId: user.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
spaceId: page.spaceId,
|
includeSubPages: createShareDto.includeSubPages,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ShareInfoDto } from './dto/share.dto';
|
import { ShareInfoDto } from './dto/share.dto';
|
||||||
@ -20,9 +21,12 @@ import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
|||||||
import { updateAttachmentAttr } from './share.util';
|
import { updateAttachmentAttr } from './share.util';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
|
import { sql } from 'kysely';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareService {
|
export class ShareService {
|
||||||
|
private readonly logger = new Logger(ShareService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly shareRepo: ShareRepo,
|
private readonly shareRepo: ShareRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
@ -33,49 +37,39 @@ export class ShareService {
|
|||||||
async createShare(opts: {
|
async createShare(opts: {
|
||||||
authUserId: string;
|
authUserId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
pageId: string;
|
page: Page;
|
||||||
spaceId: string;
|
includeSubPages: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { authUserId, workspaceId, pageId, spaceId } = opts;
|
const { authUserId, workspaceId, page, includeSubPages } = opts;
|
||||||
let share = null;
|
|
||||||
try {
|
try {
|
||||||
const slugId = generateSlugId();
|
const shares = await this.shareRepo.findByPageId(page.id);
|
||||||
share = await this.shareRepo.insertShare({
|
if (shares) {
|
||||||
slugId,
|
return shares;
|
||||||
pageId,
|
}
|
||||||
workspaceId,
|
|
||||||
|
return await this.shareRepo.insertShare({
|
||||||
|
key: generateSlugId(),
|
||||||
|
pageId: page.id,
|
||||||
|
includeSubPages: includeSubPages,
|
||||||
creatorId: authUserId,
|
creatorId: authUserId,
|
||||||
spaceId: spaceId,
|
spaceId: page.spaceId,
|
||||||
|
workspaceId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new BadRequestException('Failed to share page');
|
this.logger.error(err);
|
||||||
|
throw new BadRequestException('Failed to create page');
|
||||||
}
|
}
|
||||||
|
|
||||||
return share;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShare(dto: ShareInfoDto, workspaceId: string) {
|
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
|
||||||
const share = await this.shareRepo.findById(dto.shareId);
|
const share = await this.getShareStatus(dto.pageId, workspaceId);
|
||||||
|
|
||||||
if (!share || share.workspaceId !== workspaceId) {
|
if (!share) {
|
||||||
throw new NotFoundException('Share not found');
|
throw new NotFoundException('Shared page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetPageId = share.pageId;
|
const page = await this.pageRepo.findById(dto.pageId, {
|
||||||
if (dto.pageId && dto.pageId !== share.pageId) {
|
|
||||||
// Check if dto.pageId is a descendant of the shared page.
|
|
||||||
const isDescendant = await this.getShareAncestorPage(
|
|
||||||
share.pageId,
|
|
||||||
dto.pageId,
|
|
||||||
);
|
|
||||||
if (isDescendant) {
|
|
||||||
targetPageId = dto.pageId;
|
|
||||||
} else {
|
|
||||||
throw new NotFoundException(`Shared page not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = await this.pageRepo.findById(targetPageId, {
|
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeCreator: true,
|
includeCreator: true,
|
||||||
});
|
});
|
||||||
@ -89,6 +83,56 @@ export class ShareService {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getShareStatus(pageId: string, workspaceId: string) {
|
||||||
|
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
|
||||||
|
const share = await this.db
|
||||||
|
.withRecursive('page_hierarchy', (cte) =>
|
||||||
|
cte
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'parentPageId', sql`0`.as('level')])
|
||||||
|
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
|
||||||
|
.unionAll((union) =>
|
||||||
|
union
|
||||||
|
.selectFrom('pages as p')
|
||||||
|
.select([
|
||||||
|
'p.id',
|
||||||
|
'p.parentPageId',
|
||||||
|
// Increase the level by 1 for each ancestor.
|
||||||
|
sql`ph.level + 1`.as('level'),
|
||||||
|
])
|
||||||
|
.innerJoin('page_hierarchy as ph', 'ph.parentPageId', 'p.id'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('page_hierarchy')
|
||||||
|
.leftJoin('shares', 'shares.pageId', 'page_hierarchy.id')
|
||||||
|
.select([
|
||||||
|
'page_hierarchy.id as sharedPageId',
|
||||||
|
'page_hierarchy.level as level',
|
||||||
|
'shares.id as shareId',
|
||||||
|
'shares.key as shareKey',
|
||||||
|
'shares.includeSubPages as includeSubPages',
|
||||||
|
'shares.creatorId',
|
||||||
|
'shares.spaceId',
|
||||||
|
'shares.workspaceId',
|
||||||
|
'shares.createdAt',
|
||||||
|
'shares.updatedAt',
|
||||||
|
])
|
||||||
|
.where('shares.id', 'is not', null)
|
||||||
|
.orderBy('page_hierarchy.level', 'asc')
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!share || share.workspaceId != workspaceId) {
|
||||||
|
throw new NotFoundException('Shared page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.level === 1 && !share.includeSubPages) {
|
||||||
|
// we can only show a page if its shared ancestor permits it
|
||||||
|
throw new NotFoundException('Shared page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
async getShareAncestorPage(
|
async getShareAncestorPage(
|
||||||
ancestorPageId: string,
|
ancestorPageId: string,
|
||||||
childPageId: string,
|
childPageId: string,
|
||||||
|
|||||||
@ -6,11 +6,12 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn('id', 'uuid', (col) =>
|
.addColumn('id', 'uuid', (col) =>
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
)
|
)
|
||||||
.addColumn('slug_id', 'varchar', (col) => col.notNull())
|
.addColumn('key', 'varchar', (col) => col.notNull())
|
||||||
.addColumn('page_id', 'uuid', (col) =>
|
.addColumn('page_id', 'uuid', (col) =>
|
||||||
col.references('pages.id').onDelete('cascade'),
|
col.references('pages.id').onDelete('cascade'),
|
||||||
)
|
)
|
||||||
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
|
.addColumn('include_sub_pages', 'boolean', (col) => col.defaultTo(false))
|
||||||
|
.addColumn('search_indexing', 'boolean', (col) => col.defaultTo(true))
|
||||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||||
.addColumn('space_id', 'uuid', (col) =>
|
.addColumn('space_id', 'uuid', (col) =>
|
||||||
col.references('spaces.id').onDelete('cascade').notNull(),
|
col.references('spaces.id').onDelete('cascade').notNull(),
|
||||||
@ -25,13 +26,7 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
col.notNull().defaultTo(sql`now()`),
|
col.notNull().defaultTo(sql`now()`),
|
||||||
)
|
)
|
||||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||||
.addUniqueConstraint('shares_slug_id_unique', ['slug_id'])
|
.addUniqueConstraint('shares_key_unique', ['key'])
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('shares_slug_id_idx')
|
|
||||||
.on('shares')
|
|
||||||
.column('slug_id')
|
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export class ShareRepo {
|
|||||||
|
|
||||||
private baseFields: Array<keyof Share> = [
|
private baseFields: Array<keyof Share> = [
|
||||||
'id',
|
'id',
|
||||||
'slugId',
|
'key',
|
||||||
'pageId',
|
'pageId',
|
||||||
'includeSubPages',
|
'includeSubPages',
|
||||||
'creatorId',
|
'creatorId',
|
||||||
@ -58,12 +58,37 @@ export class ShareRepo {
|
|||||||
if (isValidUUID(shareId)) {
|
if (isValidUUID(shareId)) {
|
||||||
query = query.where('id', '=', shareId);
|
query = query.where('id', '=', shareId);
|
||||||
} else {
|
} else {
|
||||||
query = query.where('slugId', '=', shareId);
|
query = query.where('key', '=', shareId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.executeTakeFirst();
|
return query.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByPageId(
|
||||||
|
pageId: string,
|
||||||
|
opts?: {
|
||||||
|
includeCreator?: boolean;
|
||||||
|
withLock?: boolean;
|
||||||
|
trx?: KyselyTransaction;
|
||||||
|
},
|
||||||
|
): Promise<Share> {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.selectFrom('shares')
|
||||||
|
.select(this.baseFields)
|
||||||
|
.where('pageId', '=', pageId);
|
||||||
|
|
||||||
|
if (opts?.includeCreator) {
|
||||||
|
query = query.select((eb) => this.withCreator(eb));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts?.withLock && opts?.trx) {
|
||||||
|
query = query.forUpdate();
|
||||||
|
}
|
||||||
|
return query.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async updateShare(
|
async updateShare(
|
||||||
updatableShare: UpdatableShare,
|
updatableShare: UpdatableShare,
|
||||||
shareId: string,
|
shareId: string,
|
||||||
@ -72,7 +97,7 @@ export class ShareRepo {
|
|||||||
return dbOrTx(this.db, trx)
|
return dbOrTx(this.db, trx)
|
||||||
.updateTable('shares')
|
.updateTable('shares')
|
||||||
.set({ ...updatableShare, updatedAt: new Date() })
|
.set({ ...updatableShare, updatedAt: new Date() })
|
||||||
.where(!isValidUUID(shareId) ? 'slugId' : 'id', '=', shareId)
|
.where(!isValidUUID(shareId) ? 'key' : 'id', '=', shareId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +119,7 @@ export class ShareRepo {
|
|||||||
if (isValidUUID(shareId)) {
|
if (isValidUUID(shareId)) {
|
||||||
query = query.where('id', '=', shareId);
|
query = query.where('id', '=', shareId);
|
||||||
} else {
|
} else {
|
||||||
query = query.where('slugId', '=', shareId);
|
query = query.where('key', '=', shareId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await query.execute();
|
await query.execute();
|
||||||
|
|||||||
3
apps/server/src/database/types/db.d.ts
vendored
3
apps/server/src/database/types/db.d.ts
vendored
@ -189,8 +189,9 @@ export interface Shares {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
includeSubPages: Generated<boolean | null>;
|
includeSubPages: Generated<boolean | null>;
|
||||||
|
key: string;
|
||||||
pageId: string | null;
|
pageId: string | null;
|
||||||
slugId: string;
|
searchIndexing: Generated<boolean>;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user