mirror of
https://github.com/docmost/docmost.git
synced 2025-11-20 12:11:13 +10:00
- public attachment links
- WIP
This commit is contained in:
@ -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];
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateShareDto } from './create-share.dto';
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateShareDto extends PartialType(CreateShareDto) {
|
||||
//@IsString()
|
||||
//pageId: string;
|
||||
export class UpdateShareDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ import { ShareIdDto, ShareInfoDto } from './dto/share.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('shares')
|
||||
@ -30,14 +32,27 @@ 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('/info')
|
||||
async getPage(@Body() dto: ShareInfoDto) {
|
||||
return this.shareService.getShare(dto);
|
||||
async getShare(
|
||||
@Body() dto: ShareInfoDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.shareService.getShare(dto, workspace.id);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -47,15 +62,14 @@ export class ShareController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
|
||||
const page = await this.pageRepo.findById(createShareDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
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.Page)) {
|
||||
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@ -63,43 +77,41 @@ export class ShareController {
|
||||
pageId: page.id,
|
||||
authUserId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() updatePageDto: UpdateShareDto, @AuthUser() user: User) {
|
||||
/* const page = await this.pageRepo.findById(updatePageDto.pageId);
|
||||
async update(@Body() updateShareDto: UpdateShareDto, @AuthUser() user: User) {
|
||||
const share = await this.shareRepo.findById(updateShareDto.shareId);
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
if (!share) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
//return this.shareService.update(page, updatePageDto, user.id);
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() shareIdDto: ShareIdDto, @AuthUser() user: User) {
|
||||
/* const page = await this.pageRepo.findById(pageIdDto.pageId);
|
||||
const share = await this.shareRepo.findById(shareIdDto.shareId);
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
if (!share) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
const ability = await this.spaceAbility.createForUser(user, share.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
*/
|
||||
// await this.shareService.forceDelete(pageIdDto.pageId);
|
||||
|
||||
await this.shareRepo.deleteShare(share.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShareController } from './share.controller';
|
||||
import { ShareService } from './share.service';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
|
||||
@ -1,48 +1,61 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ShareInfoDto } from './dto/share.dto';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { generateSlugId } from '../../common/helpers';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { jsonToNode } from '../../collaboration/collaboration.util';
|
||||
import {
|
||||
getAttachmentIds,
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(
|
||||
private readonly shareRepo: ShareRepo,
|
||||
private readonly pageRepo: PageRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async createShare(opts: {
|
||||
authUserId: string;
|
||||
workspaceId: string;
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
}) {
|
||||
const { authUserId, workspaceId, pageId } = opts;
|
||||
|
||||
const slugId = generateSlugId(); // or custom slug
|
||||
const share = this.db
|
||||
.insertInto('shares')
|
||||
.values({ slugId: slugId, pageId, creatorId: authUserId, workspaceId })
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
const { authUserId, workspaceId, pageId, spaceId } = opts;
|
||||
let share = null;
|
||||
try {
|
||||
const slugId = generateSlugId();
|
||||
share = await this.shareRepo.insertShare({
|
||||
slugId,
|
||||
pageId,
|
||||
workspaceId,
|
||||
creatorId: authUserId,
|
||||
spaceId: spaceId,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Failed to share page');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async getShare(dto: ShareInfoDto) {
|
||||
// for now only single page share
|
||||
async getShare(dto: ShareInfoDto, workspaceId: string) {
|
||||
const share = await this.shareRepo.findById(dto.shareId);
|
||||
|
||||
// if only share Id is provided, return
|
||||
|
||||
// if share id is pass with page id, what to do?
|
||||
// if uuid is used, use Id
|
||||
const share = await this.db
|
||||
.selectFrom('shares')
|
||||
.selectAll()
|
||||
.where('slugId', '=', dto.shareId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!share) {
|
||||
if (!share || share.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
@ -51,10 +64,7 @@ export class ShareService {
|
||||
includeCreator: true,
|
||||
});
|
||||
|
||||
// cleanup json content
|
||||
// remove comments mark
|
||||
// make sure attachments work (videos, images, excalidraw, drawio)
|
||||
// figure out internal links?
|
||||
page.content = await this.updatePublicAttachments(page);
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
@ -62,4 +72,35 @@ export class ShareService {
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
async updatePublicAttachments(page: Page): Promise<any> {
|
||||
const attachmentIds = getAttachmentIds(page.content);
|
||||
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(page.content as any);
|
||||
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user