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