feat: public page sharing (#1012)

* Share - WIP

* - public attachment links
- WIP

* WIP

* WIP

* Share - WIP

* WIP

* WIP

* include userRole in space object

* WIP

* Server render shared page meta tags

* disable user select

* Close Navbar on outside click on mobile

* update shared page spaceId

* WIP

* fix

* close sidebar on click

* close sidebar

* defaults

* update copy

* Store share key in lowercase

* refactor page breadcrumbs

* Change copy

* add link ref

* open link button

* add meta og:title

* add twitter tags

* WIP

* make shares/info endpoint public

* fix

* * add /p/ segment to share urls
* minore fixes

* change mobile breadcrumb icon
This commit is contained in:
Philip Okugbe
2025-04-22 20:37:32 +01:00
committed by GitHub
parent 3e8824435d
commit 6c422011ac
66 changed files with 3331 additions and 512 deletions

View File

@ -37,18 +37,18 @@
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.1.1",
"@nestjs/bullmq": "^11.0.2",
"@nestjs/common": "^11.0.10",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.10",
"@nestjs/common": "^11.0.20",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.20",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.0.10",
"@nestjs/platform-socket.io": "^11.0.10",
"@nestjs/platform-fastify": "^11.0.20",
"@nestjs/platform-socket.io": "^11.0.20",
"@nestjs/schedule": "^5.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.0.10",
"@nestjs/websockets": "^11.0.20",
"@node-saml/passport-saml": "^5.0.1",
"@react-email/components": "0.0.28",
"@react-email/render": "1.0.2",

View File

@ -1,5 +1,6 @@
import { Node } from '@tiptap/pm/model';
import { jsonToNode } from '../../../collaboration/collaboration.util';
import { validate as isValidUUID } from 'uuid';
export interface MentionNode {
id: string;
@ -56,3 +57,41 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
}
return pageMentionList as MentionNode[];
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}

View File

@ -1,310 +1,373 @@
import {
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Query,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {AttachmentService} from './services/attachment.service';
import {FastifyReply} from 'fastify';
import {FileInterceptor} from '../../common/interceptors/file.interceptor';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import {AuthUser} from '../../common/decorators/auth-user.decorator';
import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator';
import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard';
import {User, Workspace} from '@docmost/db/types/entity.types';
import {StorageService} from '../../integrations/storage/storage.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
validAttachmentTypes,
getAttachmentFolderPath,
validAttachmentTypes,
} from './attachment.utils';
import {getMimeType} from '../../common/helpers';
import { getMimeType } from '../../common/helpers';
import {
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
} from './attachment.constants';
import {
SpaceCaslAction,
SpaceCaslSubject,
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import {PageRepo} from '@docmost/db/repos/page/page.repo';
import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import {validate as isValidUUID} from 'uuid';
import {EnvironmentService} from "../../integrations/environment/environment.service";
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
@Controller()
export class AttachmentController {
private readonly logger = new Logger(AttachmentController.name);
private readonly logger = new Logger(AttachmentController.name);
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
) {
}
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService,
) {}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
throw new NotFoundException();
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
throw new NotFoundException();
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'private, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
@Get('/files/public/:fileId/:fileName')
async getPublicFile(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
@Query('jwt') jwtToken?: string,
) {
let jwtPayload: JwtAttachmentPayload = null;
try {
jwtPayload = await this.tokenService.verifyJwt(
jwtToken,
JwtType.ATTACHMENT,
);
} catch (err) {
throw new BadRequestException(
'Expired or invalid attachment access token',
);
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
if (
!isValidUUID(fileId) ||
fileId !== jwtPayload.attachmentId ||
jwtPayload.workspaceId !== workspace.id
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
throw new NotFoundException('File not found');
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId ||
jwtPayload.pageId !== attachment.pageId
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
throw new NotFoundException('File not found');
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'private, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
}

View File

@ -5,9 +5,10 @@ import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
import { TokenModule } from '../auth/token.module';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule],
imports: [StorageModule, UserModule, WorkspaceModule, TokenModule],
controllers: [AttachmentController],
providers: [AttachmentService, AttachmentProcessor],
})

View File

@ -2,6 +2,7 @@ export enum JwtType {
ACCESS = 'access',
COLLAB = 'collab',
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
}
export type JwtPayload = {
sub: string;
@ -21,3 +22,11 @@ export type JwtExchangePayload = {
workspaceId: string;
type: 'exchange';
};
export type JwtAttachmentPayload = {
attachmentId: string;
pageId: string;
workspaceId: string;
type: 'attachment';
};

View File

@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
JwtPayload,
@ -59,6 +60,21 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '10s' });
}
async generateAttachmentToken(opts: {
attachmentId: string;
pageId: string;
workspaceId: string;
}): Promise<string> {
const { attachmentId, pageId, workspaceId } = opts;
const payload: JwtAttachmentPayload = {
attachmentId: attachmentId,
pageId: pageId,
workspaceId: workspaceId,
type: JwtType.ATTACHMENT,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),

View File

@ -45,6 +45,7 @@ function buildSpaceAdminAbility() {
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -55,6 +56,7 @@ function buildSpaceWriterAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
@ -65,5 +67,6 @@ function buildSpaceReaderAbility() {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}

View File

@ -9,9 +9,11 @@ export enum SpaceCaslSubject {
Settings = 'settings',
Member = 'member',
Page = 'page',
Share = 'share',
}
export type ISpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member]
| [SpaceCaslAction, SpaceCaslSubject.Page];
| [SpaceCaslAction, SpaceCaslSubject.Page]
| [SpaceCaslAction, SpaceCaslSubject.Share];

View File

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

View File

@ -212,7 +212,7 @@ export class PageService {
trx,
);
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id)
.getPageAndDescendants(rootPage.id, { includeContent: false })
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id
if (pageIds.length > 1) {
@ -223,6 +223,16 @@ export class PageService {
trx,
);
}
// update spaceId in shares
if (pageIds.length > 0) {
await trx
.updateTable('shares')
.set({ spaceId: spaceId })
.where('pageId', 'in', pageIds)
.execute();
}
// Update attachments
await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId },

View File

@ -0,0 +1,58 @@
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class CreateShareDto {
@IsString()
@IsNotEmpty()
pageId: string;
@IsBoolean()
@IsOptional()
includeSubPages: boolean;
@IsOptional()
@IsBoolean()
searchIndexing: boolean;
}
export class UpdateShareDto extends CreateShareDto {
@IsString()
@IsNotEmpty()
shareId: string;
@IsString()
@IsOptional()
pageId: string;
}
export class ShareIdDto {
@IsString()
@IsNotEmpty()
shareId: string;
}
export class SpaceIdDto {
@IsUUID()
spaceId: string;
}
export class ShareInfoDto {
@IsString()
@IsOptional()
shareId?: string;
@IsString()
@IsOptional()
pageId: string;
}
export class SharePageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}

View File

@ -0,0 +1,109 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import { ShareService } from './share.service';
import { FastifyReply, FastifyRequest } from 'fastify';
import { join } from 'path';
import * as fs from 'node:fs';
import { validate as isValidUUID } from 'uuid';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { Workspace } from '@docmost/db/types/entity.types';
@Controller('share')
export class ShareSeoController {
constructor(
private readonly shareService: ShareService,
private workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
) {}
/*
* add meta tags to publicly shared pages
*/
@Get([':shareId/p/:pageSlug', 'p/:pageSlug'])
async getShare(
@Res({ passthrough: false }) res: FastifyReply,
@Req() req: FastifyRequest,
@Param('shareId') shareId: string,
@Param('pageSlug') pageSlug: string,
) {
// Nestjs does not to apply middlewares to paths excluded from the global /api prefix
// https://github.com/nestjs/nest/issues/9124
// https://github.com/nestjs/nest/issues/11572
// https://github.com/nestjs/nest/issues/13401
// we have to duplicate the DomainMiddleware code here as a workaround
let workspace: Workspace = null;
if (this.environmentService.isSelfHosted()) {
workspace = await this.workspaceRepo.findFirst();
} else {
const header = req.raw.headers.host;
const subdomain = header.split('.')[0];
workspace = await this.workspaceRepo.findByHostname(subdomain);
}
const clientDistPath = join(
__dirname,
'..',
'..',
'..',
'..',
'client/dist',
);
if (fs.existsSync(clientDistPath)) {
const indexFilePath = join(clientDistPath, 'index.html');
if (!workspace) {
return this.sendIndex(indexFilePath, res);
}
const pageId = this.extractPageSlugId(pageSlug);
const share = await this.shareService.getShareForPage(
pageId,
workspace.id,
);
if (!share) {
return this.sendIndex(indexFilePath, res);
}
const rawTitle = share.sharedPage.title ?? 'untitled';
const metaTitle =
rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}` : rawTitle;
const metaTagVar = '<!--meta-tags-->';
const metaTags = [
`<meta property="og:title" content="${metaTitle}" />`,
`<meta property="twitter:title" content="${metaTitle}" />`,
!share.searchIndexing ? `<meta name="robots" content="noindex" />` : '',
]
.filter(Boolean)
.join('\n ');
const html = fs.readFileSync(indexFilePath, 'utf8');
const transformedHtml = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${metaTitle}</title>`)
.replace(metaTagVar, metaTags);
res.type('text/html').send(transformedHtml);
}
}
sendIndex(indexFilePath: string, res: FastifyReply) {
const stream = fs.createReadStream(indexFilePath);
res.type('text/html').send(stream);
}
extractPageSlugId(slug: string): string {
if (!slug) {
return undefined;
}
if (isValidUUID(slug)) {
return slug;
}
const parts = slug.split('-');
return parts.length > 1 ? parts[parts.length - 1] : slug;
}
}

View File

@ -0,0 +1,171 @@
import {
BadRequestException,
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 {
CreateShareDto,
ShareIdDto,
ShareInfoDto,
SharePageIdDto,
UpdateShareDto,
} 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';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@UseGuards(JwtAuthGuard)
@Controller('shares')
export class ShareController {
constructor(
private readonly shareService: ShareService,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getShares(
@AuthUser() user: User,
@Body() pagination: PaginationOptions,
) {
return this.shareRepo.getShares(user.id, pagination);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/page-info')
async getSharedPageInfo(
@Body() dto: ShareInfoDto,
@AuthWorkspace() workspace: Workspace,
) {
if (!dto.pageId && !dto.shareId) {
throw new BadRequestException();
}
return this.shareService.getSharedPage(dto, workspace.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/info')
async getShare(@Body() dto: ShareIdDto) {
const share = await this.shareRepo.findById(dto.shareId, {
includeSharedPage: true,
});
if (!share) {
throw new NotFoundException('Share not found');
}
return share;
}
@HttpCode(HttpStatus.OK)
@Post('/for-page')
async getShareForPage(
@Body() dto: SharePageIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Shared page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.getShareForPage(page.id, workspace.id);
}
@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 || 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.createShare({
page,
authUserId: user.id,
workspaceId: workspace.id,
createShareDto,
});
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(updateShareDto.shareId);
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
return this.shareService.updateShare(share.id, updateShareDto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
const share = await this.shareRepo.findById(shareIdDto.shareId);
if (!share) {
throw new NotFoundException('Share not found');
}
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
throw new ForbiddenException();
}
await this.shareRepo.deleteShare(share.id);
}
@Public()
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getSharePageTree(
@Body() dto: ShareIdDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.shareService.getShareTree(dto.shareId, workspace.id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { TokenModule } from '../auth/token.module';
import { ShareSeoController } from './share-seo.controller';
@Module({
imports: [TokenModule],
controllers: [ShareController, ShareSeoController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View File

@ -0,0 +1,297 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CreateShareDto, ShareInfoDto, UpdateShareDto } from './dto/share.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { nanoIdGen } from '../../common/helpers';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { TokenService } from '../auth/services/token.service';
import { jsonToNode } from '../../collaboration/collaboration.util';
import {
getAttachmentIds,
getProsemirrorContent,
isAttachmentNode,
} from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model';
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,
@InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService,
) {}
async getShareTree(shareId: string, workspaceId: string) {
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== workspaceId) {
throw new NotFoundException('Share not found');
}
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false,
});
return { share, pageTree: pageList };
} else {
return { share, pageTree: [] };
}
}
async createShare(opts: {
authUserId: string;
workspaceId: string;
page: Page;
createShareDto: CreateShareDto;
}) {
const { authUserId, workspaceId, page, createShareDto } = opts;
try {
const shares = await this.shareRepo.findByPageId(page.id);
if (shares) {
return shares;
}
return await this.shareRepo.insertShare({
key: nanoIdGen().toLowerCase(),
pageId: page.id,
includeSubPages: createShareDto.includeSubPages || true,
searchIndexing: createShareDto.searchIndexing || true,
creatorId: authUserId,
spaceId: page.spaceId,
workspaceId,
});
} catch (err) {
this.logger.error(err);
throw new BadRequestException('Failed to share page');
}
}
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
try {
return this.shareRepo.updateShare(
{
includeSubPages: updateShareDto.includeSubPages,
searchIndexing: updateShareDto.searchIndexing,
},
shareId,
);
} catch (err) {
this.logger.error(err);
throw new BadRequestException('Failed to update share');
}
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
const share = await this.getShareForPage(dto.pageId, workspaceId);
if (!share) {
throw new NotFoundException('Shared page not found');
}
const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true,
includeCreator: true,
});
page.content = await this.updatePublicAttachments(page);
if (!page) {
throw new NotFoundException('Shared page not found');
}
return { page, share };
}
async getShareForPage(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',
'slugId',
'pages.title',
'pages.icon',
'parentPageId',
sql`0`.as('level'),
])
.where(isValidUUID(pageId) ? 'id' : 'slugId', '=', pageId)
.unionAll((union) =>
union
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'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.slugId as sharedPageSlugId',
'page_hierarchy.title as sharedPageTitle',
'page_hierarchy.icon as sharedPageIcon',
'page_hierarchy.level as level',
'shares.id',
'shares.key',
'shares.pageId',
'shares.includeSubPages',
'shares.searchIndexing',
'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) {
return undefined;
}
if (share.level === 1 && !share.includeSubPages) {
// we can only show a page if its shared ancestor permits it
return undefined;
}
return {
id: share.id,
key: share.key,
includeSubPages: share.includeSubPages,
searchIndexing: share.searchIndexing,
pageId: share.pageId,
creatorId: share.creatorId,
spaceId: share.spaceId,
workspaceId: share.workspaceId,
createdAt: share.createdAt,
level: share.level,
sharedPage: {
id: share.sharedPageId,
slugId: share.sharedPageSlugId,
title: share.sharedPageTitle,
icon: share.sharedPageIcon,
},
};
}
async getShareAncestorPage(
ancestorPageId: string,
childPageId: string,
): Promise<any> {
let ancestor = null;
try {
ancestor = await this.db
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'parentPageId',
'spaceId',
(eb) =>
eb
.case()
.when(eb.ref('id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.where(
isValidUUID(childPageId) ? 'id' : 'slugId',
'=',
childPageId,
)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.parentPageId',
'p.spaceId',
(eb) =>
eb
.case()
.when(eb.ref('p.id'), '=', ancestorPageId)
.then(true)
.else(false)
.end()
.as('found'),
])
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id')
// Continue recursing only when the target ancestor hasn't been found on that branch.
.where('pa.found', '=', false),
),
)
.selectFrom('page_ancestors')
.selectAll()
.where('found', '=', true)
.limit(1)
.executeTakeFirst();
} catch (err) {
// empty
}
return ancestor;
}
async updatePublicAttachments(page: Page): Promise<any> {
const prosemirrorJson = getProsemirrorContent(page.content);
const attachmentIds = getAttachmentIds(prosemirrorJson);
const attachmentMap = new Map<string, string>();
await Promise.all(
attachmentIds.map(async (attachmentId: string) => {
const token = await this.tokenService.generateAttachmentToken({
attachmentId,
pageId: page.id,
workspaceId: page.workspaceId,
});
attachmentMap.set(attachmentId, token);
}),
);
const doc = jsonToNode(prosemirrorJson);
doc?.descendants((node: Node) => {
if (!isAttachmentNode(node.type.name)) return;
const attachmentId = node.attrs.attachmentId;
const token = attachmentMap.get(attachmentId);
if (!token) return;
updateAttachmentAttr(node, 'src', token);
updateAttachmentAttr(node, 'url', token);
});
return doc.toJSON();
}
}

View File

@ -0,0 +1,22 @@
import { Node } from '@tiptap/pm/model';
export function updateAttachmentAttr(
node: Node,
attr: 'src' | 'url',
token: string,
) {
const attrVal = node.attrs[attr];
if (
attrVal &&
(attrVal.startsWith('/files') || attrVal.startsWith('/api/files'))
) {
// @ts-ignore
node.attrs[attr] = updateAttachmentUrl(attrVal, token);
}
}
function updateAttachmentUrl(src: string, jwtToken: string) {
const updatedSrc = src.replace('/files/', '/files/public/');
const separator = updatedSrc.includes('?') ? '&' : '?';
return `${updatedSrc}${separator}jwt=${jwtToken}`;
}

View File

@ -24,6 +24,7 @@ import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -74,6 +75,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
exports: [
WorkspaceRepo,
@ -88,6 +90,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
AttachmentRepo,
UserTokenRepo,
BacklinkRepo,
ShareRepo
],
})
export class DatabaseModule

View File

@ -0,0 +1,38 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('shares')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.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(false))
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.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_key_workspace_id_unique', [
'key',
'workspace_id',
])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('shares').execute();
}

View File

@ -211,7 +211,10 @@ export class PageRepo {
).as('contributors');
}
async getPageAndDescendants(parentPageId: string) {
async getPageAndDescendants(
parentPageId: string,
opts: { includeContent: boolean },
) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
@ -221,11 +224,12 @@ export class PageRepo {
'slugId',
'title',
'icon',
'content',
'position',
'parentPageId',
'spaceId',
'workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('id', '=', parentPageId)
.unionAll((exp) =>
exp
@ -235,11 +239,12 @@ export class PageRepo {
'p.slugId',
'p.title',
'p.icon',
'p.content',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id'),
),
)

View File

@ -0,0 +1,242 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
import {
InsertableShare,
Share,
UpdatableShare,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { validate as isValidUUID } from 'uuid';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class ShareRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private spaceMemberRepo: SpaceMemberRepo,
) {}
private baseFields: Array<keyof Share> = [
'id',
'key',
'pageId',
'includeSubPages',
'searchIndexing',
'creatorId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
shareId: string,
opts?: {
includeSharedPage?: boolean;
includeCreator?: boolean;
withLock?: boolean;
trx?: KyselyTransaction;
},
): Promise<Share> {
const db = dbOrTx(this.db, opts?.trx);
let query = db.selectFrom('shares').select(this.baseFields);
if (opts?.includeSharedPage) {
query = query.select((eb) => this.withSharedPage(eb));
}
if (opts?.includeCreator) {
query = query.select((eb) => this.withCreator(eb));
}
if (opts?.withLock && opts?.trx) {
query = query.forUpdate();
}
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
}
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(
updatableShare: UpdatableShare,
shareId: string,
trx?: KyselyTransaction,
) {
return dbOrTx(this.db, trx)
.updateTable('shares')
.set({ ...updatableShare, updatedAt: new Date() })
.where(
isValidUUID(shareId) ? 'id' : sql`LOWER(key)`,
'=',
shareId.toLowerCase(),
)
.returning(this.baseFields)
.executeTakeFirst();
}
async insertShare(
insertableShare: InsertableShare,
trx?: KyselyTransaction,
): Promise<Share> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('shares')
.values(insertableShare)
.returning(this.baseFields)
.executeTakeFirst();
}
async deleteShare(shareId: string): Promise<void> {
let query = this.db.deleteFrom('shares');
if (isValidUUID(shareId)) {
query = query.where('id', '=', shareId);
} else {
query = query.where(sql`LOWER(key)`, '=', shareId.toLowerCase());
}
await query.execute();
}
async getShares(userId: string, pagination: PaginationOptions) {
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
const query = this.db
.selectFrom('shares')
.select(this.baseFields)
.select((eb) => this.withPage(eb))
.select((eb) => this.withSpace(eb, userId))
.select((eb) => this.withCreator(eb))
.where('spaceId', 'in', userSpaceIds)
.orderBy('updatedAt', 'desc');
const hasEmptyIds = userSpaceIds.length === 0;
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
hasEmptyIds,
});
return result;
}
withPage(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select(['pages.id', 'pages.title', 'pages.slugId', 'pages.icon'])
.whereRef('pages.id', '=', 'shares.pageId'),
).as('page');
}
withSpace(eb: ExpressionBuilder<DB, 'shares'>, userId?: string) {
return jsonObjectFrom(
eb
.selectFrom('spaces')
.select(['spaces.id', 'spaces.name', 'spaces.slug'])
.$if(Boolean(userId), (qb) =>
qb.select((eb) => this.withUserSpaceRole(eb, userId)),
)
.whereRef('spaces.id', '=', 'shares.spaceId'),
).as('space');
}
withUserSpaceRole(eb: ExpressionBuilder<DB, 'spaces'>, userId: string) {
return eb
.selectFrom(
eb
.selectFrom('spaceMembers')
.select(['spaceMembers.role'])
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.where('spaceMembers.userId', '=', userId)
.unionAll(
eb
.selectFrom('spaceMembers')
.innerJoin(
'groupUsers',
'groupUsers.groupId',
'spaceMembers.groupId',
)
.select(['spaceMembers.role'])
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.where('groupUsers.userId', '=', userId),
)
.as('roles_union'),
)
.select('roles_union.role')
.orderBy(
sql`CASE roles_union.role
WHEN 'admin' THEN 3
WHEN 'writer' THEN 2
WHEN 'reader' THEN 1
ELSE 0
END`,
'desc',
)
.limit(1)
.as('userRole');
}
withCreator(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(['users.id', 'users.name', 'users.avatarUrl'])
.whereRef('users.id', '=', 'shares.creatorId'),
).as('creator');
}
withSharedPage(eb: ExpressionBuilder<DB, 'shares'>) {
return jsonObjectFrom(
eb
.selectFrom('pages')
.select([
'pages.id',
'pages.slugId',
'pages.title',
'pages.icon',
'pages.parentPageId',
])
.whereRef('pages.id', '=', 'shares.pageId'),
).as('sharedPage');
}
}

View File

@ -183,6 +183,20 @@ export interface Pages {
ydoc: Buffer | null;
}
export interface Shares {
createdAt: Generated<Timestamp>;
creatorId: string | null;
deletedAt: Timestamp | null;
id: Generated<string>;
includeSubPages: Generated<boolean | null>;
key: string;
pageId: string | null;
searchIndexing: Generated<boolean | null>;
spaceId: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface SpaceMembers {
addedById: string | null;
createdAt: Generated<Timestamp>;
@ -288,6 +302,7 @@ export interface DB {
groupUsers: GroupUsers;
pageHistory: PageHistory;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
users: Users;

View File

@ -16,6 +16,7 @@ import {
Billing as BillingSubscription,
AuthProviders,
AuthAccounts,
Shares,
} from './db';
// Workspace
@ -101,3 +102,8 @@ export type UpdatableAuthProvider = Updateable<Omit<AuthProviders, 'id'>>;
export type AuthAccount = Selectable<AuthAccounts>;
export type InsertableAuthAccount = Insertable<AuthAccounts>;
export type UpdatableAuthAccount = Updateable<Omit<AuthAccounts, 'id'>>;
// Share
export type Share = Selectable<Shares>;
export type InsertableShare = Insertable<Shares>;
export type UpdatableShare = Updateable<Omit<Shares, 'id'>>;

View File

@ -15,10 +15,8 @@ import { StorageService } from '../storage/storage.service';
import {
buildTree,
computeLocalPath,
getAttachmentIds,
getExportExtension,
getPageTitle,
getProsemirrorContent,
PageExportTree,
replaceInternalLinks,
updateAttachmentUrlsToLocalPaths,
@ -29,6 +27,10 @@ import { EditorState } from '@tiptap/pm/state';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
import { EnvironmentService } from '../environment/environment.service';
import {
getAttachmentIds,
getProsemirrorContent,
} from '../../common/helpers/prosemirror/utils';
@Injectable()
export class ExportService {
@ -76,8 +78,11 @@ export class ExportService {
</html>`;
}
if (format === ExportFormat.Markdown) {
const newPageHtml = pageHtml.replace(/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gmi, '');
if (format === ExportFormat.Markdown) {
const newPageHtml = pageHtml.replace(
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
'',
);
return turndown(newPageHtml);
}
@ -85,7 +90,9 @@ export class ExportService {
}
async exportPageWithChildren(pageId: string, format: string) {
const pages = await this.pageRepo.getPageAndDescendants(pageId);
const pages = await this.pageRepo.getPageAndDescendants(pageId, {
includeContent: true,
});
if (!pages || pages.length === 0) {
throw new BadRequestException('No pages to export');
@ -260,14 +267,7 @@ export class ExportService {
const pages = await this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'creatorId',
'spaceId',
'workspaceId',
])
.select(['id', 'slugId', 'title', 'creatorId', 'spaceId', 'workspaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where('id', 'in', pageMentionIds)
.where('workspaceId', '=', workspaceId)

View File

@ -4,6 +4,7 @@ import { Node } from '@tiptap/pm/model';
import { validate as isValidUUID } from 'uuid';
import * as path from 'path';
import { Page } from '@docmost/db/types/entity.types';
import { isAttachmentNode } from '../../common/helpers/prosemirror/utils';
export type PageExportTree = Record<string, Page[]>;
@ -25,43 +26,6 @@ export function getPageTitle(title: string) {
return title ? title : 'untitled';
}
export function getProsemirrorContent(content: any) {
return (
content ?? {
type: 'doc',
content: [{ type: 'paragraph', attrs: { textAlign: 'left' } }],
}
);
}
export function getAttachmentIds(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
const attachmentIds = [];
doc?.descendants((node: Node) => {
if (isAttachmentNode(node.type.name)) {
if (node.attrs.attachmentId && isValidUUID(node.attrs.attachmentId)) {
if (!attachmentIds.includes(node.attrs.attachmentId)) {
attachmentIds.push(node.attrs.attachmentId);
}
}
}
});
return attachmentIds;
}
export function isAttachmentNode(nodeType: string) {
const attachmentNodeTypes = [
'attachment',
'image',
'video',
'excalidraw',
'drawio',
];
return attachmentNodeTypes.includes(nodeType);
}
export function updateAttachmentUrlsToLocalPaths(prosemirrorJson: any) {
const doc = jsonToNode(prosemirrorJson);
if (!doc) return null;

View File

@ -4,7 +4,12 @@ import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Logger, NotFoundException, ValidationPipe } from '@nestjs/common';
import {
Logger,
NotFoundException,
RequestMethod,
ValidationPipe,
} from '@nestjs/common';
import { TransformHttpResponseInterceptor } from './common/interceptors/http-response.interceptor';
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import { InternalLogFilter } from './common/logger/internal-log-filter';
@ -26,7 +31,9 @@ async function bootstrap() {
},
);
app.setGlobalPrefix('api', { exclude: ['robots.txt'] });
app.setGlobalPrefix('api', {
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug'],
});
const reflector = app.get(Reflector);
const redisIoAdapter = new WsRedisIoAdapter(app);