mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 05:01:09 +10:00
switch to nx monorepo
This commit is contained in:
110
apps/server/src/core/attachment/attachment.controller.ts
Normal file
110
apps/server/src/core/attachment/attachment.controller.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AttachmentInterceptor } from './attachment.interceptor';
|
||||
import { JwtUser } from '../../decorators/jwt-user.decorator';
|
||||
import { JwtGuard } from '../auth/guards/JwtGuard';
|
||||
import * as bytes from 'bytes';
|
||||
|
||||
@Controller('attachments')
|
||||
export class AttachmentController {
|
||||
constructor(private readonly attachmentService: AttachmentService) {}
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/avatar')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadAvatar(
|
||||
@JwtUser() jwtUser,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const maxFileSize = bytes('5MB');
|
||||
|
||||
try {
|
||||
const file = req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadAvatar(
|
||||
file,
|
||||
jwtUser.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/workspace-logo')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadWorkspaceLogo(
|
||||
@JwtUser() jwtUser,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const maxFileSize = bytes('5MB');
|
||||
|
||||
try {
|
||||
const file = req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
|
||||
// TODO FIX
|
||||
const workspaceId = '123';
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadWorkspaceLogo(
|
||||
file,
|
||||
workspaceId,
|
||||
jwtUser.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post('upload/file')
|
||||
@UseInterceptors(AttachmentInterceptor)
|
||||
async uploadFile(
|
||||
@JwtUser() jwtUser,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const maxFileSize = bytes('20MB');
|
||||
|
||||
try {
|
||||
const file = req.file({
|
||||
limits: { fileSize: maxFileSize, fields: 1, files: 1 },
|
||||
});
|
||||
|
||||
const workspaceId = '123';
|
||||
|
||||
const fileResponse = await this.attachmentService.uploadWorkspaceLogo(
|
||||
file,
|
||||
workspaceId,
|
||||
jwtUser.id,
|
||||
);
|
||||
|
||||
return res.send(fileResponse);
|
||||
} catch (err) {
|
||||
throw new BadRequestException('Error processing file upload.');
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/server/src/core/attachment/attachment.interceptor.ts
Normal file
25
apps/server/src/core/attachment/attachment.interceptor.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentInterceptor implements NestInterceptor {
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<any> {
|
||||
const req: FastifyRequest = context.switchToHttp().getRequest();
|
||||
|
||||
if (!req.isMultipart() || !req.file) {
|
||||
throw new BadRequestException('Invalid multipart content type');
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
23
apps/server/src/core/attachment/attachment.module.ts
Normal file
23
apps/server/src/core/attachment/attachment.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AttachmentService } from './attachment.service';
|
||||
import { AttachmentController } from './attachment.controller';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Attachment]),
|
||||
StorageModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
],
|
||||
controllers: [AttachmentController],
|
||||
providers: [AttachmentService, AttachmentRepository],
|
||||
})
|
||||
export class AttachmentModule {}
|
||||
18
apps/server/src/core/attachment/attachment.service.spec.ts
Normal file
18
apps/server/src/core/attachment/attachment.service.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
164
apps/server/src/core/attachment/attachment.service.ts
Normal file
164
apps/server/src/core/attachment/attachment.service.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { AttachmentRepository } from './repositories/attachment.repository';
|
||||
import { Attachment } from './entities/attachment.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentService {
|
||||
constructor(
|
||||
private readonly storageService: StorageService,
|
||||
private readonly attachmentRepo: AttachmentRepository,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
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(userId: string, avatarUrl: string) {
|
||||
const updateUserDto = new UpdateUserDto();
|
||||
updateUserDto.avatarUrl = avatarUrl;
|
||||
await this.userService.update(userId, updateUserDto);
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
|
||||
const attachment = new Attachment();
|
||||
|
||||
attachment.creatorId = userId;
|
||||
attachment.pageId = null;
|
||||
attachment.workspaceId = null;
|
||||
attachment.type = AttachmentType.Avatar;
|
||||
attachment.filePath = filePath;
|
||||
attachment.fileName = preparedFile.fileName;
|
||||
attachment.fileSize = preparedFile.fileSize;
|
||||
attachment.mimeType = preparedFile.mimeType;
|
||||
attachment.fileExt = preparedFile.fileExtension;
|
||||
|
||||
await this.updateUserAvatar(userId, filePath);
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const attachment = new Attachment();
|
||||
|
||||
attachment.creatorId = userId;
|
||||
attachment.pageId = null;
|
||||
attachment.workspaceId = workspaceId;
|
||||
attachment.type = AttachmentType.WorkspaceLogo;
|
||||
attachment.filePath = filePath;
|
||||
attachment.fileName = preparedFile.fileName;
|
||||
attachment.fileSize = preparedFile.fileSize;
|
||||
attachment.mimeType = preparedFile.mimeType;
|
||||
attachment.fileExt = preparedFile.fileExtension;
|
||||
|
||||
await this.updateWorkspaceLogo(workspaceId, filePath);
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
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 = new Attachment();
|
||||
|
||||
attachment.creatorId = userId;
|
||||
attachment.pageId = pageId;
|
||||
attachment.workspaceId = workspaceId;
|
||||
attachment.type = AttachmentType.WorkspaceLogo;
|
||||
attachment.filePath = filePath;
|
||||
attachment.fileName = preparedFile.fileName;
|
||||
attachment.fileSize = preparedFile.fileSize;
|
||||
attachment.mimeType = preparedFile.mimeType;
|
||||
attachment.fileExt = preparedFile.fileExtension;
|
||||
|
||||
return attachment;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new BadRequestException(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
apps/server/src/core/attachment/attachment.utils.ts
Normal file
75
apps/server/src/core/attachment/attachment.utils.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { MultipartFile } from '@fastify/multipart';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface PreparedFile {
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileExtension: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function prepareFile(
|
||||
filePromise: Promise<MultipartFile>,
|
||||
): Promise<PreparedFile> {
|
||||
try {
|
||||
const rand = randomBytes(4).toString('hex');
|
||||
const file = await filePromise;
|
||||
|
||||
if (!file) {
|
||||
throw new Error('No file provided');
|
||||
}
|
||||
|
||||
const buffer = await file.toBuffer();
|
||||
const sanitizedFilename = sanitize(file.filename).replace(/ /g, '_');
|
||||
const fileName = `${rand}_${sanitizedFilename}`;
|
||||
const fileSize = buffer.length;
|
||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName,
|
||||
fileSize,
|
||||
fileExtension,
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in file preparation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFileType(
|
||||
fileExtension: string,
|
||||
allowedTypes: string[],
|
||||
) {
|
||||
if (!allowedTypes.includes(fileExtension)) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
}
|
||||
|
||||
export enum AttachmentType {
|
||||
Avatar = 'Avatar',
|
||||
WorkspaceLogo = 'WorkspaceLogo',
|
||||
File = 'file',
|
||||
}
|
||||
|
||||
export function getAttachmentPath(
|
||||
type: AttachmentType,
|
||||
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';
|
||||
case AttachmentType.WorkspaceLogo:
|
||||
return `${workspaceId}/logo`;
|
||||
default:
|
||||
return `${workspaceId}/files`;
|
||||
}
|
||||
}
|
||||
5
apps/server/src/core/attachment/dto/avatar-upload.dto.ts
Normal file
5
apps/server/src/core/attachment/dto/avatar-upload.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvatarUploadDto {
|
||||
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Page } from '../../page/entities/page.entity';
|
||||
import { Workspace } from '../../workspace/entities/workspace.entity';
|
||||
|
||||
@Entity('attachments')
|
||||
export class Attachment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
fileName: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
filePath: string;
|
||||
|
||||
@Column({ type: 'bigint' })
|
||||
fileSize: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 55 })
|
||||
fileExt: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
mimeType: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 55 })
|
||||
type: string; // e.g. page / workspace / avatar
|
||||
|
||||
@Column()
|
||||
creatorId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'creatorId' })
|
||||
creator: User;
|
||||
|
||||
@Column({ nullable: true })
|
||||
pageId: string;
|
||||
|
||||
@ManyToOne(() => Page)
|
||||
@JoinColumn({ name: 'pageId' })
|
||||
page: Page;
|
||||
|
||||
@Column({ nullable: true })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Workspace;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@DeleteDateColumn({ nullable: true })
|
||||
deletedAt: Date;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Attachment } from '../entities/attachment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AttachmentRepository extends Repository<Attachment> {
|
||||
constructor(private dataSource: DataSource) {
|
||||
super(Attachment, dataSource.createEntityManager());
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.findOneBy({ id: id });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user