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

@ -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}`;
}