mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 05:01:09 +10:00
more work on attachments
* fix frontend env usage
This commit is contained in:
12
apps/server/src/core/attachment/attachment.constants.ts
Normal file
12
apps/server/src/core/attachment/attachment.constants.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export enum AttachmentType {
|
||||
Avatar = 'avatar',
|
||||
WorkspaceLogo = 'workspace-logo',
|
||||
SpaceLogo = 'space-logo',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
|
||||
export const MAX_AVATAR_SIZE = '5MB';
|
||||
|
||||
export const validFileExtensions = ['.jpg', '.png', '.jpeg', '.pdf'];
|
||||
export const MAX_FILE_SIZE = '20MB';
|
||||
@ -1,111 +1,226 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AttachmentInterceptor } from './attachment.interceptor';
|
||||
import { AttachmentService } from './services/attachment.service';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { AttachmentInterceptor } from './interceptors/attachment.interceptor';
|
||||
import * as bytes from 'bytes';
|
||||
import { AuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../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 '../../helpers';
|
||||
import {
|
||||
AttachmentType,
|
||||
MAX_AVATAR_SIZE,
|
||||
MAX_FILE_SIZE,
|
||||
} from './attachment.constants';
|
||||
import CaslAbilityFactory from '../casl/abilities/casl-ability.factory';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../casl/interfaces/space-ability.type';
|
||||
import { Action } from '../casl/ability.action';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
|
||||
@Controller('attachments')
|
||||
export class AttachmentController {
|
||||
constructor(private readonly attachmentService: AttachmentService) {}
|
||||
private readonly logger = new Logger(AttachmentController.name);
|
||||
|
||||
constructor(
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly caslAbility: CaslAbilityFactory,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/avatar')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadAvatar(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const maxFileSize = bytes('5MB');
|
||||
|
||||
try {
|
||||
const file = req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadAvatar(
|
||||
file,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/workspace-logo')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadWorkspaceLogo(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const maxFileSize = bytes('5MB');
|
||||
|
||||
try {
|
||||
const file = req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadWorkspaceLogo(
|
||||
file,
|
||||
workspace.id,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/file')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('upload-file')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadFile(
|
||||
@Req() req: FastifyRequest,
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const maxFileSize = bytes('20MB');
|
||||
const maxFileSize = bytes(MAX_FILE_SIZE);
|
||||
|
||||
let file = null;
|
||||
try {
|
||||
const file = req.file({
|
||||
file = await req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 413) {
|
||||
throw new BadRequestException(
|
||||
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadWorkspaceLogo(
|
||||
if (!file) {
|
||||
throw new BadRequestException('Invalid file upload');
|
||||
}
|
||||
|
||||
const pageId = file.fields?.pageId.value;
|
||||
|
||||
if (!pageId) {
|
||||
throw new BadRequestException('PageId is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const fileResponse = await this.attachmentService.uploadFile(
|
||||
file,
|
||||
workspace.id,
|
||||
pageId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/:fileId/:fileName')
|
||||
async getFile(
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
@Param('fileId') fileId: string,
|
||||
@Param('fileName') fileName?: string,
|
||||
) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('upload-image')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
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.caslAbility.createForUser(user, workspace);
|
||||
if (ability.cannot(Action.Manage, 'Workspace')) {
|
||||
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('/img/:attachmentType/:fileName')
|
||||
async getLogoOrAvatar(
|
||||
@Req() req: any,
|
||||
@Res() res: FastifyReply,
|
||||
@Param('attachmentType') attachmentType: AttachmentType,
|
||||
@Param('fileName') fileName?: string,
|
||||
) {
|
||||
const workspaceId = req.raw?.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException('Invalid workspace');
|
||||
}
|
||||
|
||||
if (
|
||||
!validAttachmentTypes.includes(attachmentType) ||
|
||||
attachmentType === AttachmentType.File
|
||||
) {
|
||||
throw new BadRequestException('Invalid image attachment type');
|
||||
}
|
||||
|
||||
const buildFilePath = `${getAttachmentFolderPath(attachmentType, workspaceId)}/${fileName}`;
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.read(buildFilePath);
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(buildFilePath),
|
||||
});
|
||||
return res.send(fileStream);
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { AttachmentService } from './services/attachment.service';
|
||||
import { AttachmentController } from './attachment.controller';
|
||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
|
||||
describe('AttachmentService', () => {
|
||||
let service: AttachmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AttachmentService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AttachmentService>(AttachmentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@ -1,168 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { StorageService } from '../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { UpdateUserDto } from '../user/dto/update-user.dto';
|
||||
import {
|
||||
AttachmentType,
|
||||
getAttachmentPath,
|
||||
PreparedFile,
|
||||
prepareFile,
|
||||
validateFileType,
|
||||
} from './attachment.utils';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { WorkspaceService } from '../workspace/services/workspace.service';
|
||||
import { UpdateWorkspaceDto } from '../workspace/dto/update-workspace.dto';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
|
||||
// TODO: make code better
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly userService: UserService,
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
) {}
|
||||
|
||||
async uploadToDrive(preparedFile: PreparedFile, filePath: string) {
|
||||
try {
|
||||
await this.storageService.upload(filePath, preparedFile.buffer);
|
||||
} catch (err) {
|
||||
console.error('Error uploading file to drive:', err);
|
||||
throw new BadRequestException('Error uploading file to drive');
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserAvatar(avatarUrl: string, userId: string, workspaceId) {
|
||||
const updateUserDto = new UpdateUserDto();
|
||||
updateUserDto.avatarUrl = avatarUrl;
|
||||
await this.userService.update(updateUserDto, userId, workspaceId);
|
||||
}
|
||||
|
||||
async updateWorkspaceLogo(workspaceId: string, logoUrl: string) {
|
||||
const updateWorkspaceDto = new UpdateWorkspaceDto();
|
||||
updateWorkspaceDto.logo = logoUrl;
|
||||
await this.workspaceService.update(workspaceId, updateWorkspaceDto);
|
||||
}
|
||||
|
||||
async uploadAvatar(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
try {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
const allowedImageTypes = ['.jpg', '.jpeg', '.png'];
|
||||
|
||||
validateFileType(preparedFile.fileExtension, allowedImageTypes);
|
||||
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const attachmentPath = getAttachmentPath(AttachmentType.Avatar);
|
||||
const filePath = `${attachmentPath}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(preparedFile, filePath);
|
||||
|
||||
// todo: in transaction
|
||||
const attachment = await this.attachmentRepo.insertAttachment({
|
||||
creatorId: userId,
|
||||
type: AttachmentType.Avatar,
|
||||
filePath: filePath,
|
||||
fileName: preparedFile.fileName,
|
||||
fileSize: preparedFile.fileSize,
|
||||
mimeType: preparedFile.mimeType,
|
||||
fileExt: preparedFile.fileExtension,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
await this.updateUserAvatar(filePath, userId, workspaceId);
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadWorkspaceLogo(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
try {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
const allowedImageTypes = ['.jpg', '.jpeg', '.png'];
|
||||
|
||||
validateFileType(preparedFile.fileExtension, allowedImageTypes);
|
||||
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const attachmentPath = getAttachmentPath(
|
||||
AttachmentType.WorkspaceLogo,
|
||||
workspaceId,
|
||||
);
|
||||
const filePath = `${attachmentPath}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(preparedFile, filePath);
|
||||
|
||||
// todo: in trx
|
||||
const attachment = await this.attachmentRepo.insertAttachment({
|
||||
creatorId: userId,
|
||||
type: AttachmentType.WorkspaceLogo,
|
||||
filePath: filePath,
|
||||
fileName: preparedFile.fileName,
|
||||
fileSize: preparedFile.fileSize,
|
||||
mimeType: preparedFile.mimeType,
|
||||
fileExt: preparedFile.fileExtension,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
await this.updateWorkspaceLogo(workspaceId, filePath);
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
pageId: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
) {
|
||||
try {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
const allowedImageTypes = ['.jpg', '.jpeg', '.png', '.pdf'];
|
||||
|
||||
validateFileType(preparedFile.fileExtension, allowedImageTypes);
|
||||
|
||||
const attachmentPath = getAttachmentPath(
|
||||
AttachmentType.WorkspaceLogo,
|
||||
workspaceId,
|
||||
);
|
||||
const filePath = `${attachmentPath}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(preparedFile, filePath);
|
||||
|
||||
const attachment = await this.attachmentRepo.insertAttachment({
|
||||
creatorId: userId,
|
||||
pageId: pageId,
|
||||
type: AttachmentType.File,
|
||||
filePath: filePath,
|
||||
fileName: preparedFile.fileName,
|
||||
fileSize: preparedFile.fileSize,
|
||||
mimeType: preparedFile.mimeType,
|
||||
fileExt: preparedFile.fileExtension,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException('Failed to upload file');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { MultipartFile } from '@fastify/multipart';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
import { AttachmentType } from './attachment.constants';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer: Buffer;
|
||||
@ -14,13 +15,14 @@ export interface PreparedFile {
|
||||
export async function prepareFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
): Promise<PreparedFile> {
|
||||
try {
|
||||
const rand = randomBytes(4).toString('hex');
|
||||
const file = await filePromise;
|
||||
const file = await filePromise;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const rand = randomBytes(8).toString('hex');
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
||||
@ -50,26 +52,22 @@ export function validateFileType(
|
||||
}
|
||||
}
|
||||
|
||||
export enum AttachmentType {
|
||||
Avatar = 'Avatar',
|
||||
WorkspaceLogo = 'WorkspaceLogo',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export function getAttachmentPath(
|
||||
export function getAttachmentFolderPath(
|
||||
type: AttachmentType,
|
||||
workspaceId?: string,
|
||||
workspaceId: string,
|
||||
): string {
|
||||
if (!workspaceId && type != AttachmentType.Avatar) {
|
||||
throw new Error('Workspace ID is required for this attachment type');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case AttachmentType.Avatar:
|
||||
return 'avatars';
|
||||
return `${workspaceId}/avatars`;
|
||||
case AttachmentType.WorkspaceLogo:
|
||||
return `${workspaceId}/logo`;
|
||||
return `${workspaceId}/workspace-logo`;
|
||||
case AttachmentType.SpaceLogo:
|
||||
return `${workspaceId}/space-logos`;
|
||||
case AttachmentType.File:
|
||||
return `${workspaceId}/files`;
|
||||
default:
|
||||
return `${workspaceId}/files`;
|
||||
}
|
||||
}
|
||||
|
||||
export const validAttachmentTypes = Object.values(AttachmentType);
|
||||
|
||||
7
apps/server/src/core/attachment/dto/get-file.dto.ts
Normal file
7
apps/server/src/core/attachment/dto/get-file.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class GetFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentId: string;
|
||||
}
|
||||
20
apps/server/src/core/attachment/dto/upload-file.dto.ts
Normal file
20
apps/server/src/core/attachment/dto/upload-file.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {
|
||||
IsDefined,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
attachmentType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
pageId: string;
|
||||
|
||||
@IsDefined()
|
||||
file: any;
|
||||
}
|
||||
213
apps/server/src/core/attachment/services/attachment.service.ts
Normal file
213
apps/server/src/core/attachment/services/attachment.service.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import {
|
||||
getAttachmentFolderPath,
|
||||
PreparedFile,
|
||||
prepareFile,
|
||||
validateFileType,
|
||||
} from '../attachment.utils';
|
||||
import { v4 as uuid4 } from 'uuid';
|
||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||
import {
|
||||
AttachmentType,
|
||||
validFileExtensions,
|
||||
validImageExtensions,
|
||||
} from '../attachment.constants';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { Attachment } from '@docmost/db/types/entity.types';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
private readonly logger = new Logger(AttachmentService.name);
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
private readonly attachmentRepo: AttachmentRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
async uploadFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validFileExtensions);
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||
|
||||
let attachment: Attachment = null;
|
||||
try {
|
||||
attachment = await this.saveAttachment({
|
||||
preparedFile,
|
||||
filePath,
|
||||
type: AttachmentType.File,
|
||||
userId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
});
|
||||
} catch (err) {
|
||||
// delete uploaded file on error
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
type:
|
||||
| AttachmentType.Avatar
|
||||
| AttachmentType.WorkspaceLogo
|
||||
| AttachmentType.SpaceLogo,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
spaceId?: string,
|
||||
) {
|
||||
const preparedFile: PreparedFile = await prepareFile(filePromise);
|
||||
validateFileType(preparedFile.fileExtension, validImageExtensions);
|
||||
|
||||
preparedFile.fileName = uuid4() + preparedFile.fileExtension;
|
||||
|
||||
const filePath = `${getAttachmentFolderPath(type, workspaceId)}/${preparedFile.fileName}`;
|
||||
|
||||
await this.uploadToDrive(filePath, preparedFile.buffer);
|
||||
|
||||
let attachment: Attachment = null;
|
||||
let oldFileName: string = null;
|
||||
|
||||
try {
|
||||
await executeTx(this.db, async (trx) => {
|
||||
attachment = await this.saveAttachment({
|
||||
preparedFile,
|
||||
filePath,
|
||||
type,
|
||||
userId,
|
||||
workspaceId,
|
||||
trx,
|
||||
});
|
||||
|
||||
if (type === AttachmentType.Avatar) {
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
oldFileName = user.avatarUrl;
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ avatarUrl: preparedFile.fileName },
|
||||
userId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.WorkspaceLogo) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
oldFileName = workspace.logo;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
{ logo: preparedFile.fileName },
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else if (type === AttachmentType.SpaceLogo && spaceId) {
|
||||
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
oldFileName = space.logo;
|
||||
|
||||
await this.spaceRepo.updateSpace(
|
||||
{ logo: preparedFile.fileName },
|
||||
spaceId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
} else {
|
||||
throw new BadRequestException(`Image upload aborted.`);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// delete uploaded file on db update failure
|
||||
this.logger.error('Image upload error:', err);
|
||||
await this.deleteRedundantFile(filePath);
|
||||
throw new BadRequestException('Failed to upload image');
|
||||
}
|
||||
|
||||
if (oldFileName && !oldFileName.toLowerCase().startsWith('http')) {
|
||||
// delete old avatar or logo
|
||||
const oldFilePath =
|
||||
getAttachmentFolderPath(type, workspaceId) + '/' + oldFileName;
|
||||
await this.deleteRedundantFile(oldFilePath);
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async deleteRedundantFile(filePath: string) {
|
||||
try {
|
||||
await this.storageService.delete(filePath);
|
||||
await this.attachmentRepo.deleteAttachmentByFilePath(filePath);
|
||||
} catch (error) {
|
||||
this.logger.error('deleteRedundantFile', error);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadToDrive(filePath: string, fileBuffer: any) {
|
||||
try {
|
||||
await this.storageService.upload(filePath, fileBuffer);
|
||||
} catch (err) {
|
||||
this.logger.error('Error uploading file to drive:', err);
|
||||
throw new BadRequestException('Error uploading file to drive');
|
||||
}
|
||||
}
|
||||
|
||||
async saveAttachment(opts: {
|
||||
preparedFile: PreparedFile;
|
||||
filePath: string;
|
||||
type: AttachmentType;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
pageId?: string;
|
||||
spaceId?: string;
|
||||
trx?: KyselyTransaction;
|
||||
}): Promise<Attachment> {
|
||||
const {
|
||||
preparedFile,
|
||||
filePath,
|
||||
type,
|
||||
userId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
spaceId,
|
||||
trx,
|
||||
} = opts;
|
||||
return this.attachmentRepo.insertAttachment(
|
||||
{
|
||||
type: type,
|
||||
filePath: filePath,
|
||||
fileName: preparedFile.fileName,
|
||||
fileSize: preparedFile.fileSize,
|
||||
mimeType: preparedFile.mimeType,
|
||||
fileExt: preparedFile.fileExtension,
|
||||
creatorId: userId,
|
||||
workspaceId: workspaceId,
|
||||
pageId: pageId,
|
||||
spaceId: spaceId,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user