mirror of
https://github.com/docmost/docmost.git
synced 2025-11-14 02:41:11 +10:00
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:
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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';
|
||||
};
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 },
|
||||
|
||||
58
apps/server/src/core/share/dto/share.dto.ts
Normal file
58
apps/server/src/core/share/dto/share.dto.ts
Normal 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;
|
||||
}
|
||||
109
apps/server/src/core/share/share-seo.controller.ts
Normal file
109
apps/server/src/core/share/share-seo.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
171
apps/server/src/core/share/share.controller.ts
Normal file
171
apps/server/src/core/share/share.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/core/share/share.module.ts
Normal file
13
apps/server/src/core/share/share.module.ts
Normal 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 {}
|
||||
297
apps/server/src/core/share/share.service.ts
Normal file
297
apps/server/src/core/share/share.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
22
apps/server/src/core/share/share.util.ts
Normal file
22
apps/server/src/core/share/share.util.ts
Normal 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}`;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
242
apps/server/src/database/repos/share/share.repo.ts
Normal file
242
apps/server/src/database/repos/share/share.repo.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
15
apps/server/src/database/types/db.d.ts
vendored
15
apps/server/src/database/types/db.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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'>>;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user