{
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;