mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 01:02:43 +10:00
* 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
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import {
|
|
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 * 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 {
|
|
getAttachmentFolderPath,
|
|
validAttachmentTypes,
|
|
} from './attachment.utils';
|
|
import { getMimeType } from '../../common/helpers';
|
|
import {
|
|
AttachmentType,
|
|
inlineFileExtensions,
|
|
MAX_AVATAR_SIZE,
|
|
} from './attachment.constants';
|
|
import {
|
|
SpaceCaslAction,
|
|
SpaceCaslSubject,
|
|
} from '../casl/interfaces/space-ability.type';
|
|
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
|
import {
|
|
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 { 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);
|
|
|
|
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());
|
|
|
|
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,
|
|
);
|
|
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
|
|
) {
|
|
throw new NotFoundException();
|
|
}
|
|
|
|
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)}"`,
|
|
);
|
|
}
|
|
|
|
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',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!isValidUUID(fileId) ||
|
|
fileId !== jwtPayload.attachmentId ||
|
|
jwtPayload.workspaceId !== workspace.id
|
|
) {
|
|
throw new NotFoundException('File not found');
|
|
}
|
|
|
|
const attachment = await this.attachmentRepo.findById(fileId);
|
|
if (
|
|
!attachment ||
|
|
attachment.workspaceId !== workspace.id ||
|
|
!attachment.pageId ||
|
|
!attachment.spaceId ||
|
|
jwtPayload.pageId !== attachment.pageId
|
|
) {
|
|
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');
|
|
}
|
|
}
|
|
}
|