From 18e8c4cbaf7b5d544a2db361639ae75f200c1674 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:26:50 +0100 Subject: [PATCH] Share - WIP --- apps/client/src/App.tsx | 3 + .../editor/components/drawio/drawio-view.tsx | 2 +- .../components/excalidraw/excalidraw-view.tsx | 2 +- .../features/editor/readonly-page-editor.tsx | 52 +++++++++ apps/client/src/features/page/page.utils.ts | 8 +- .../src/features/share/queries/share-query.ts | 65 +++++++++++ .../features/share/services/share-service.ts | 36 ++++++ .../src/features/share/types/share.types.ts | 12 ++ apps/client/src/pages/share/shared-page.tsx | 56 ++++++++++ apps/server/src/core/core.module.ts | 2 + .../src/core/share/dto/create-share.dto.ts | 6 + apps/server/src/core/share/dto/share.dto.ts | 28 +++++ .../src/core/share/dto/update-page.dto.ts | 8 ++ .../server/src/core/share/share.controller.ts | 105 ++++++++++++++++++ apps/server/src/core/share/share.module.ts | 10 ++ apps/server/src/core/share/share.service.ts | 65 +++++++++++ .../migrations/20250408T191830-shares.ts | 41 +++++++ apps/server/src/database/types/db.d.ts | 13 +++ .../server/src/database/types/entity.types.ts | 6 + 19 files changed, 514 insertions(+), 6 deletions(-) create mode 100644 apps/client/src/features/editor/readonly-page-editor.tsx create mode 100644 apps/client/src/features/share/queries/share-query.ts create mode 100644 apps/client/src/features/share/services/share-service.ts create mode 100644 apps/client/src/features/share/types/share.types.ts create mode 100644 apps/client/src/pages/share/shared-page.tsx create mode 100644 apps/server/src/core/share/dto/create-share.dto.ts create mode 100644 apps/server/src/core/share/dto/share.dto.ts create mode 100644 apps/server/src/core/share/dto/update-page.dto.ts create mode 100644 apps/server/src/core/share/share.controller.ts create mode 100644 apps/server/src/core/share/share.module.ts create mode 100644 apps/server/src/core/share/share.service.ts create mode 100644 apps/server/src/database/migrations/20250408T191830-shares.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c806f852..f2aef597 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -26,6 +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'; export default function App() { const { t } = useTranslation(); @@ -51,6 +52,8 @@ export default function App() { )} + } /> + } /> }> diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index 16e6dc97..468e26a0 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -139,7 +139,7 @@ export default function DrawioView(props: NodeViewProps) { )} /> - {selected && ( + {selected && editor.isEditable && ( - {selected && ( + {selected && editor.isEditable && ( { + return [...mainExtensions]; + }, []); + + const titleExtensions = [ + Document.extend({ + content: "heading", + }), + Heading, + Text, + Placeholder.configure({ + placeholder: "Untitled", + showOnlyWhenEditable: false, + }), + ]; + + return ( + <> + + + + + ); +} diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts index fc8de53d..7ff823d5 100644 --- a/apps/client/src/features/page/page.utils.ts +++ b/apps/client/src/features/page/page.utils.ts @@ -1,6 +1,6 @@ import slugify from "@sindresorhus/slugify"; -const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => { +export const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => { const titleSlug = slugify(pageTitle?.substring(0, 70) || "untitled", { customReplacements: [ ["♥", ""], @@ -8,7 +8,7 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => { ], }); - return `p/${titleSlug}-${pageSlugId}`; + return `${titleSlug}-${pageSlugId}`; }; export const buildPageUrl = ( @@ -17,7 +17,7 @@ export const buildPageUrl = ( pageTitle?: string, ): string => { if (spaceName === undefined) { - return `/${buildPageSlug(pageSlugId, pageTitle)}`; + return `/p/${buildPageSlug(pageSlugId, pageTitle)}`; } - return `/s/${spaceName}/${buildPageSlug(pageSlugId, pageTitle)}`; + return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; }; diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts new file mode 100644 index 00000000..e49025c2 --- /dev/null +++ b/apps/client/src/features/share/queries/share-query.ts @@ -0,0 +1,65 @@ +import { + useMutation, + useQuery, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { validate as isValidUuid } from "uuid"; +import { useTranslation } from "react-i18next"; +import { + ICreateShare, + IShareInput, +} from "@/features/share/types/share.types.ts"; +import { + createShare, + deleteShare, + getShare, + updateShare, +} from "@/features/share/services/share-service.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; + +export function useShareQuery( + shareInput: Partial, +): UseQueryResult { + const query = useQuery({ + queryKey: ["shares", shareInput], + queryFn: () => getShare(shareInput), + enabled: !!shareInput.shareId, + staleTime: 5 * 60 * 1000, + }); + + return query; +} + +export function useCreateShareMutation() { + const { t } = useTranslation(); + return useMutation({ + mutationFn: (data) => createShare(data), + onSuccess: (data) => {}, + onError: (error) => { + notifications.show({ message: t("Failed to share page"), color: "red" }); + }, + }); +} + +export function useUpdateShareMutation() { + return useMutation>({ + mutationFn: (data) => updateShare(data), + }); +} + +export function useDeleteShareMutation() { + const { t } = useTranslation(); + return useMutation({ + mutationFn: (shareId: string) => deleteShare(shareId), + onSuccess: () => { + notifications.show({ message: t("Share deleted successfully") }); + }, + onError: (error) => { + notifications.show({ + message: t("Failed to delete share"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/share/services/share-service.ts b/apps/client/src/features/share/services/share-service.ts new file mode 100644 index 00000000..d44f3564 --- /dev/null +++ b/apps/client/src/features/share/services/share-service.ts @@ -0,0 +1,36 @@ +import api from "@/lib/api-client"; +import { + IExportPageParams, + IMovePage, + IMovePageToSpace, + IPage, + IPageInput, + SidebarPagesParams, +} from "@/features/page/types/page.types"; +import { IAttachment, IPagination } from "@/lib/types.ts"; +import { saveAs } from "file-saver"; +import { + ICreateShare, + IShareInput, +} from "@/features/share/types/share.types.ts"; + +export async function createShare(data: ICreateShare): Promise { + const req = await api.post("/shares/create", data); + return req.data; +} + +export async function getShare( + shareInput: Partial, +): Promise { + const req = await api.post("/shares/info", shareInput); + return req.data; +} + +export async function updateShare(data: Partial): Promise { + const req = await api.post("/shares/update", data); + return req.data; +} + +export async function deleteShare(shareId: string): Promise { + await api.post("/shares/delete", { shareId }); +} diff --git a/apps/client/src/features/share/types/share.types.ts b/apps/client/src/features/share/types/share.types.ts new file mode 100644 index 00000000..100a5756 --- /dev/null +++ b/apps/client/src/features/share/types/share.types.ts @@ -0,0 +1,12 @@ + +export interface ICreateShare { + slugId: string; + pageId: string; +} + + +export interface IShareInput { + shareId: string; + pageId?: string; +} + diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx new file mode 100644 index 00000000..310a81b6 --- /dev/null +++ b/apps/client/src/pages/share/shared-page.tsx @@ -0,0 +1,56 @@ +import { useNavigate, 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 React, { useEffect } from "react"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; +import { buildPageSlug } from "@/features/page/page.utils.ts"; + +export default function SharedPage() { + const { t } = useTranslation(); + const { shareId } = useParams(); + const { + data: page, + isLoading, + isError, + error, + } = useShareQuery({ shareId: shareId }); + const navigate = useNavigate(); + + useEffect(() => { + if (!page) return; + const pageSlug = buildPageSlug(page.slugId, page.title); + const shareSlug = `/share/${shareId}/${pageSlug}`; + navigate(shareSlug, { replace: true }); + }, [page]); + + if (isLoading) { + return <>; + } + + if (isError || !page) { + if ([401, 403, 404].includes(error?.["status"])) { + return
{t("Page not found")}
; + } + return
{t("Error fetching page data.")}
; + } + + return ( + page && ( +
+ + {`${page?.icon || ""} ${page?.title || t("untitled")}`} + + + + + +
+ ) + ); +} diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index 182a1420..f7f4f785 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -15,6 +15,7 @@ import { SpaceModule } from './space/space.module'; import { GroupModule } from './group/group.module'; import { CaslModule } from './casl/casl.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; +import { ShareModule } from './share/share.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware'; SpaceModule, GroupModule, CaslModule, + ShareModule, ], }) export class CoreModule implements NestModule { diff --git a/apps/server/src/core/share/dto/create-share.dto.ts b/apps/server/src/core/share/dto/create-share.dto.ts new file mode 100644 index 00000000..9d200c45 --- /dev/null +++ b/apps/server/src/core/share/dto/create-share.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class CreateShareDto { + @IsString() + pageId: string; +} diff --git a/apps/server/src/core/share/dto/share.dto.ts b/apps/server/src/core/share/dto/share.dto.ts new file mode 100644 index 00000000..213313f2 --- /dev/null +++ b/apps/server/src/core/share/dto/share.dto.ts @@ -0,0 +1,28 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class ShareIdDto { + @IsString() + @IsNotEmpty() + shareId: string; +} + +export class SpaceIdDto { + @IsUUID() + spaceId: string; +} + +export class ShareInfoDto extends ShareIdDto { + @IsString() + @IsOptional() + pageId: string; + + // @IsOptional() + // @IsBoolean() + // includeContent: boolean; +} diff --git a/apps/server/src/core/share/dto/update-page.dto.ts b/apps/server/src/core/share/dto/update-page.dto.ts new file mode 100644 index 00000000..447c5ffa --- /dev/null +++ b/apps/server/src/core/share/dto/update-page.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateShareDto } from './create-share.dto'; +import { IsString } from 'class-validator'; + +export class UpdateShareDto extends PartialType(CreateShareDto) { + //@IsString() + //pageId: string; +} diff --git a/apps/server/src/core/share/share.controller.ts b/apps/server/src/core/share/share.controller.ts new file mode 100644 index 00000000..10a6dee6 --- /dev/null +++ b/apps/server/src/core/share/share.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +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 { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; + +@UseGuards(JwtAuthGuard) +@Controller('shares') +export class ShareController { + constructor( + private readonly shareService: ShareService, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly pageRepo: PageRepo, + ) {} + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/info') + async getPage(@Body() dto: ShareInfoDto) { + return this.shareService.getShare(dto); + } + + @HttpCode(HttpStatus.OK) + @Post('create') + async create( + @Body() createShareDto: CreateShareDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + + const page = await this.pageRepo.findById(createShareDto.pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.shareService.createShare({ + pageId: page.id, + authUserId: user.id, + workspaceId: workspace.id, + }); + } + + @HttpCode(HttpStatus.OK) + @Post('update') + async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) { + /* const page = await this.pageRepo.findById(updatePageDto.pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + //return this.shareService.update(page, updatePageDto, user.id); + + */ + } + + @HttpCode(HttpStatus.OK) + @Post('delete') + async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) { + /* const page = await this.pageRepo.findById(pageIdDto.pageId); + + if (!page) { + throw new NotFoundException('Page not found'); + } + + const ability = await this.spaceAbility.createForUser(user, page.spaceId); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + */ + // await this.shareService.forceDelete(pageIdDto.pageId); + } +} diff --git a/apps/server/src/core/share/share.module.ts b/apps/server/src/core/share/share.module.ts new file mode 100644 index 00000000..efef9b6d --- /dev/null +++ b/apps/server/src/core/share/share.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ShareController } from './share.controller'; +import { ShareService } from './share.service'; + +@Module({ + controllers: [ShareController], + providers: [ShareService], + exports: [ShareService], +}) +export class ShareModule {} diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts new file mode 100644 index 00000000..87d69710 --- /dev/null +++ b/apps/server/src/core/share/share.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ShareInfoDto } from './dto/share.dto'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { generateSlugId } from '../../common/helpers'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; + +@Injectable() +export class ShareService { + constructor( + private readonly pageRepo: PageRepo, + @InjectKysely() private readonly db: KyselyDB, + ) {} + + async createShare(opts: { + authUserId: string; + workspaceId: string; + pageId: string; + }) { + const { authUserId, workspaceId, pageId } = opts; + + const slugId = generateSlugId(); // or custom slug + const share = this.db + .insertInto('shares') + .values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId }) + .returningAll() + .executeTakeFirst(); + + return share; + } + + async getShare(dto: ShareInfoDto) { + // for now only single page share + + // if only share Id is provided, return + + // if share id is pass with page id, what to do? + // if uuid is used, use Id + const share = await this.db + .selectFrom('shares') + .selectAll() + .where('slugId', '=', dto.shareId) + .executeTakeFirst(); + + if (!share) { + throw new NotFoundException('Share not found'); + } + + const page = await this.pageRepo.findById(share.pageId, { + includeContent: true, + includeCreator: true, + }); + + // cleanup json content + // remove comments mark + // make sure attachments work (videos, images, excalidraw, drawio) + // figure out internal links? + + if (!page) { + throw new NotFoundException('Page not found'); + } + + return page; + } +} diff --git a/apps/server/src/database/migrations/20250408T191830-shares.ts b/apps/server/src/database/migrations/20250408T191830-shares.ts new file mode 100644 index 00000000..db745321 --- /dev/null +++ b/apps/server/src/database/migrations/20250408T191830-shares.ts @@ -0,0 +1,41 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('shares') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('slug_id', 'varchar', (col) => col.notNull()) + .addColumn('page_id', 'varchar', (col) => col.notNull()) + .addColumn('include_sub_pages', 'varchar', (col) => col) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + + // pageSlug + + //.addColumn('space_id', 'uuid', (col) => + // col.references('spaces.id').onDelete('cascade').notNull(), + // ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + 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') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('shares').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index eae94943..2a634e01 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -183,6 +183,18 @@ export interface Pages { ydoc: Buffer | null; } +export interface Shares { + createdAt: Generated; + creatorId: string | null; + deletedAt: Timestamp | null; + id: Generated; + includeSubPages: string | null; + pageId: string; + slugId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface SpaceMembers { addedById: string | null; createdAt: Generated; @@ -288,6 +300,7 @@ export interface DB { groupUsers: GroupUsers; pageHistory: PageHistory; pages: Pages; + shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; users: Users; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 8abd9f98..6cb55a11 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -16,6 +16,7 @@ import { Billing as BillingSubscription, AuthProviders, AuthAccounts, + Shares, } from './db'; // Workspace @@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable>; export type AuthAccount = Selectable; export type InsertableAuthAccount = Insertable; export type UpdatableAuthAccount = Updateable>; + +// Share +export type Share = Selectable; +export type InsertableShare = Insertable; +export type UpdatableShare = Updateable>;