From 8dff3e22401e9643354a832158dbcd219c98c48f Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:59:00 +0100 Subject: [PATCH] WIP --- apps/client/src/App.tsx | 3 +- .../components/mention/mention-view.tsx | 9 +- .../components/header/page-header-menu.tsx | 3 + apps/client/src/features/page/page.utils.ts | 4 + .../features/share/components/share-modal.tsx | 88 ++++++++++++++ .../src/features/share/queries/share-query.ts | 22 +++- .../features/share/services/share-service.ts | 10 ++ .../src/features/share/types/share.types.ts | 4 +- apps/client/src/pages/share/shared-page.tsx | 14 ++- .../src/core/share/dto/create-share.dto.ts | 3 +- apps/server/src/core/share/dto/share.dto.ts | 16 ++- .../server/src/core/share/share.controller.ts | 34 +++++- apps/server/src/core/share/share.service.ts | 108 ++++++++++++------ .../migrations/20250408T191830-shares.ts | 11 +- .../src/database/repos/share/share.repo.ts | 33 +++++- apps/server/src/database/types/db.d.ts | 3 +- 16 files changed, 293 insertions(+), 72 deletions(-) create mode 100644 apps/client/src/features/share/components/share-modal.tsx diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 06dc4230..5bfa52cf 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -26,7 +26,7 @@ import { useTranslation } from "react-i18next"; import Security from "@/ee/security/pages/security.tsx"; import License from "@/ee/licence/pages/license.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() { const { t } = useTranslation(); @@ -53,6 +53,7 @@ export default function App() { )} } /> + } /> } /> diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx index 706742ae..d42e4a8c 100644 --- a/apps/client/src/features/editor/components/mention/mention-view.tsx +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -1,7 +1,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { ActionIcon, Anchor, Text } from "@mantine/core"; 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 { buildPageUrl, @@ -20,6 +20,9 @@ export default function MentionView(props: NodeViewProps) { isError, } = usePageQuery({ pageId: entityType === "page" ? slugId : null }); + const location = useLocation(); + const isShareRoute = location.pathname.startsWith("/share"); + const shareSlugUrl = buildSharedPageUrl({ shareId, pageSlugId: slugId, @@ -38,7 +41,9 @@ export default function MentionView(props: NodeViewProps) { diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 93d10520..5f7bfc26 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -35,6 +35,7 @@ import { import { formattedDate, timeAgo } from "@/lib/time.ts"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; +import ShareModal from '@/features/share/components/share-modal.tsx'; interface PageHeaderMenuProps { readOnly?: boolean; @@ -58,6 +59,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} + + { const { shareId, pageSlugId, pageTitle } = opts; + if (!shareId) { + return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; + } + return `/share/${shareId}/${buildPageSlug(pageSlugId, pageTitle)}`; }; diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx new file mode 100644 index 00000000..9b91804f --- /dev/null +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -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 ( + + + + + + +
+ {t("Make page public")} +
+ +
+ + + } + /> + +
+
+ ); +} + +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) => { + const value = event.currentTarget.checked; + setChecked(value); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts index f22100ff..3d4e74d2 100644 --- a/apps/client/src/features/share/queries/share-query.ts +++ b/apps/client/src/features/share/queries/share-query.ts @@ -1,8 +1,4 @@ -import { - useMutation, - useQuery, - UseQueryResult, -} from "@tanstack/react-query"; +import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query"; import { notifications } from "@mantine/notifications"; import { validate as isValidUuid } from "uuid"; import { useTranslation } from "react-i18next"; @@ -14,6 +10,7 @@ import { createShare, deleteShare, getShareInfo, + getShareStatus, updateShare, } from "@/features/share/services/share-service.ts"; import { IPage } from "@/features/page/types/page.types.ts"; @@ -24,7 +21,20 @@ export function useShareQuery( const query = useQuery({ queryKey: ["shares", shareInput], queryFn: () => getShareInfo(shareInput), - enabled: !!shareInput.shareId, + enabled: !!shareInput.pageId, + staleTime: 5 * 60 * 1000, + }); + + return query; +} + +export function useShareStatusQuery( + pageId: string, +): UseQueryResult { + const query = useQuery({ + queryKey: ["share-status", pageId], + queryFn: () => getShareStatus(pageId), + enabled: !!pageId, staleTime: 5 * 60 * 1000, }); diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts index e0c03819..efa28a4a 100644 --- a/apps/client/src/features/share/services/share-service.ts +++ b/apps/client/src/features/share/services/share-service.ts @@ -6,11 +6,21 @@ import { IShareInfoInput, } from "@/features/share/types/share.types.ts"; +export async function getShares(data: ICreateShare): Promise { + const req = await api.post("/shares", data); + return req.data; +} + export async function createShare(data: ICreateShare): Promise { const req = await api.post("/shares/create", data); return req.data; } +export async function getShareStatus(pageId: string): Promise { + const req = await api.post("/shares/status", { pageId }); + return req.data; +} + export async function getShareInfo( shareInput: Partial, ): Promise { diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts index e3b45d1c..f84131f8 100644 --- a/apps/client/src/features/share/types/share.types.ts +++ b/apps/client/src/features/share/types/share.types.ts @@ -1,10 +1,8 @@ export interface ICreateShare { - slugId: string; // share slugId pageId: string; includeSubPages?: boolean; } export interface IShareInfoInput { - shareId: string; pageId: string; -} +} \ No newline at end of file diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index 3c7f7d5f..6c44d2ff 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -2,14 +2,14 @@ import { useParams } from "react-router-dom"; import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; 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 ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import { extractPageSlugId } from "@/lib"; +import { Error404 } from "@/components/ui/error-404.tsx"; -export default function SharedPage() { +export default function SingleSharedPage() { const { t } = useTranslation(); - const { shareId } = useParams(); const { pageSlug } = useParams(); const { @@ -17,7 +17,7 @@ export default function SharedPage() { isLoading, isError, error, - } = useShareQuery({ shareId: shareId, pageId: extractPageSlugId(pageSlug) }); + } = useShareQuery({ pageId: extractPageSlugId(pageSlug) }); if (isLoading) { return <>; @@ -25,7 +25,7 @@ export default function SharedPage() { if (isError || !page) { if ([401, 403, 404].includes(error?.["status"])) { - return
{t("Page not found")}
; + return ; } return
{t("Error fetching page data.")}
; } @@ -43,6 +43,10 @@ export default function SharedPage() { content={page.content} /> + + + + ); } diff --git a/apps/server/src/core/share/dto/create-share.dto.ts b/apps/server/src/core/share/dto/create-share.dto.ts index fcc5a848..aad5bb5e 100644 --- a/apps/server/src/core/share/dto/create-share.dto.ts +++ b/apps/server/src/core/share/dto/create-share.dto.ts @@ -1,9 +1,10 @@ -import { IsBoolean, IsString } from 'class-validator'; +import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class CreateShareDto { @IsString() pageId: string; @IsBoolean() + @IsOptional() includeSubPages: boolean; } diff --git a/apps/server/src/core/share/dto/share.dto.ts b/apps/server/src/core/share/dto/share.dto.ts index 213313f2..46c609cd 100644 --- a/apps/server/src/core/share/dto/share.dto.ts +++ b/apps/server/src/core/share/dto/share.dto.ts @@ -17,12 +17,18 @@ export class SpaceIdDto { spaceId: string; } -export class ShareInfoDto extends ShareIdDto { +export class ShareInfoDto { + @IsString() + @IsOptional() + shareId: string; + @IsString() @IsOptional() pageId: string; - - // @IsOptional() - // @IsBoolean() - // includeContent: boolean; +} + +export class SharePageIdDto { + @IsString() + @IsNotEmpty() + pageId: string; } diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts index 033a1cf8..2eacaba3 100644 --- a/apps/server/src/core/share/share.controller.ts +++ b/apps/server/src/core/share/share.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, ForbiddenException, @@ -19,7 +20,7 @@ import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { ShareService } from './share.service'; import { UpdateShareDto } from './dto/update-page.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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { Public } from '../../common/decorators/public.decorator'; @@ -52,7 +53,32 @@ export class ShareController { @Body() dto: ShareInfoDto, @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) @@ -74,10 +100,10 @@ export class ShareController { } return this.shareService.createShare({ - pageId: page.id, + page, authUserId: user.id, workspaceId: workspace.id, - spaceId: page.spaceId, + includeSubPages: createShareDto.includeSubPages, }); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 0f6d3ef2..2fe70d94 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; 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 { Page } from '@docmost/db/types/entity.types'; import { validate as isValidUUID } from 'uuid'; +import { sql } from 'kysely'; @Injectable() export class ShareService { + private readonly logger = new Logger(ShareService.name); + constructor( private readonly shareRepo: ShareRepo, private readonly pageRepo: PageRepo, @@ -33,49 +37,39 @@ export class ShareService { async createShare(opts: { authUserId: string; workspaceId: string; - pageId: string; - spaceId: string; + page: Page; + includeSubPages: boolean; }) { - const { authUserId, workspaceId, pageId, spaceId } = opts; - let share = null; + const { authUserId, workspaceId, page, includeSubPages } = opts; + try { - const slugId = generateSlugId(); - share = await this.shareRepo.insertShare({ - slugId, - pageId, - workspaceId, + const shares = await this.shareRepo.findByPageId(page.id); + if (shares) { + return shares; + } + + return await this.shareRepo.insertShare({ + key: generateSlugId(), + pageId: page.id, + includeSubPages: includeSubPages, creatorId: authUserId, - spaceId: spaceId, + spaceId: page.spaceId, + workspaceId, }); } 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) { - const share = await this.shareRepo.findById(dto.shareId); + async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + const share = await this.getShareStatus(dto.pageId, workspaceId); - if (!share || share.workspaceId !== workspaceId) { - throw new NotFoundException('Share not found'); + if (!share) { + throw new NotFoundException('Shared page not found'); } - let targetPageId = share.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, { + const page = await this.pageRepo.findById(dto.pageId, { includeContent: true, includeCreator: true, }); @@ -89,6 +83,56 @@ export class ShareService { 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( ancestorPageId: string, childPageId: string, diff --git a/apps/server/src/database/migrations/20250408T191830-shares.ts b/apps/server/src/database/migrations/20250408T191830-shares.ts index 40c3e85d..439516c9 100644 --- a/apps/server/src/database/migrations/20250408T191830-shares.ts +++ b/apps/server/src/database/migrations/20250408T191830-shares.ts @@ -6,11 +6,12 @@ export async function up(db: Kysely): Promise { .addColumn('id', 'uuid', (col) => 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) => col.references('pages.id').onDelete('cascade'), ) .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('space_id', 'uuid', (col) => col.references('spaces.id').onDelete('cascade').notNull(), @@ -25,13 +26,7 @@ export async function up(db: Kysely): Promise { col.notNull().defaultTo(sql`now()`), ) .addColumn('deleted_at', 'timestamptz', (col) => col) - .addUniqueConstraint('shares_slug_id_unique', ['slug_id']) - .execute(); - - await db.schema - .createIndex('shares_slug_id_idx') - .on('shares') - .column('slug_id') + .addUniqueConstraint('shares_key_unique', ['key']) .execute(); } diff --git a/apps/server/src/database/repos/share/share.repo.ts b/apps/server/src/database/repos/share/share.repo.ts index 9d144e4e..193ca901 100644 --- a/apps/server/src/database/repos/share/share.repo.ts +++ b/apps/server/src/database/repos/share/share.repo.ts @@ -24,7 +24,7 @@ export class ShareRepo { private baseFields: Array = [ 'id', - 'slugId', + 'key', 'pageId', 'includeSubPages', 'creatorId', @@ -58,12 +58,37 @@ export class ShareRepo { if (isValidUUID(shareId)) { query = query.where('id', '=', shareId); } else { - query = query.where('slugId', '=', shareId); + query = query.where('key', '=', shareId); } return query.executeTakeFirst(); } + async findByPageId( + pageId: string, + opts?: { + includeCreator?: boolean; + withLock?: boolean; + trx?: KyselyTransaction; + }, + ): Promise { + 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( updatableShare: UpdatableShare, shareId: string, @@ -72,7 +97,7 @@ export class ShareRepo { return dbOrTx(this.db, trx) .updateTable('shares') .set({ ...updatableShare, updatedAt: new Date() }) - .where(!isValidUUID(shareId) ? 'slugId' : 'id', '=', shareId) + .where(!isValidUUID(shareId) ? 'key' : 'id', '=', shareId) .executeTakeFirst(); } @@ -94,7 +119,7 @@ export class ShareRepo { if (isValidUUID(shareId)) { query = query.where('id', '=', shareId); } else { - query = query.where('slugId', '=', shareId); + query = query.where('key', '=', shareId); } await query.execute(); diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index fda12640..1a44a655 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -189,8 +189,9 @@ export interface Shares { deletedAt: Timestamp | null; id: Generated; includeSubPages: Generated; + key: string; pageId: string | null; - slugId: string; + searchIndexing: Generated; spaceId: string; updatedAt: Generated; workspaceId: string;