251122:1700 Phase 4
This commit is contained in:
@@ -5,7 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { UserModule } from '../../modules/user/user.module.js';
|
||||
import { JwtStrategy } from './jwt.strategy.js';
|
||||
import { JwtStrategy } from '../guards/jwt.strategy.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
19
backend/src/common/decorators/current-user.decorator.ts
Normal file
19
backend/src/common/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// File: src/common/decorators/current-user.decorator.ts
|
||||
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Decorator สำหรับดึงข้อมูล User ปัจจุบันจาก Request Object
|
||||
* ใช้คู่กับ JwtAuthGuard
|
||||
*
|
||||
* ตัวอย่างการใช้:
|
||||
* @Get()
|
||||
* findAll(@CurrentUser() user: User) { ... }
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
// request.user ถูก set โดย Passport/JwtStrategy
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../../modules/user/entities/user.entity.js';
|
||||
|
||||
@Entity('attachments')
|
||||
export class Attachment {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'original_filename', length: 255 })
|
||||
originalFilename!: string;
|
||||
|
||||
@Column({ name: 'stored_filename', length: 255 })
|
||||
storedFilename!: string;
|
||||
|
||||
@Column({ name: 'file_path', length: 500 })
|
||||
filePath!: string;
|
||||
|
||||
@Column({ name: 'mime_type', length: 100 })
|
||||
mimeType!: string;
|
||||
|
||||
@Column({ name: 'file_size' })
|
||||
fileSize!: number;
|
||||
|
||||
@Column({ name: 'is_temporary', default: true })
|
||||
isTemporary!: boolean;
|
||||
|
||||
@Column({ name: 'temp_id', length: 100, nullable: true })
|
||||
tempId?: string;
|
||||
|
||||
@Column({ name: 'expires_at', type: 'datetime', nullable: true })
|
||||
expiresAt?: Date;
|
||||
|
||||
@Column({ length: 64, nullable: true })
|
||||
checksum?: string;
|
||||
|
||||
@Column({ name: 'uploaded_by_user_id' })
|
||||
uploadedByUserId!: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// Relation กับ User (คนอัปโหลด)
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'uploaded_by_user_id' })
|
||||
uploadedBy?: User;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileStorageController } from './file-storage.controller';
|
||||
|
||||
describe('FileStorageController', () => {
|
||||
let controller: FileStorageController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [FileStorageController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<FileStorageController>(FileStorageController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
49
backend/src/common/file-storage/file-storage.controller.ts
Normal file
49
backend/src/common/file-storage/file-storage.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
|
||||
|
||||
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('files')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FileStorageController {
|
||||
constructor(private readonly fileStorageService: FileStorageService) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file')) // รับ field ชื่อ 'file'
|
||||
async uploadFile(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
|
||||
// ตรวจสอบประเภทไฟล์ (Regex)
|
||||
new FileTypeValidator({
|
||||
fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any
|
||||
) {
|
||||
// ส่ง userId จาก Token ไปด้วย
|
||||
return this.fileStorageService.upload(file, req.user.userId);
|
||||
}
|
||||
}
|
||||
13
backend/src/common/file-storage/file-storage.module.ts
Normal file
13
backend/src/common/file-storage/file-storage.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FileStorageService } from './file-storage.service.js';
|
||||
import { FileStorageController } from './file-storage.controller.js';
|
||||
import { Attachment } from './entities/attachment.entity.js';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Attachment])],
|
||||
controllers: [FileStorageController],
|
||||
providers: [FileStorageService],
|
||||
exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit
|
||||
})
|
||||
export class FileStorageModule {}
|
||||
18
backend/src/common/file-storage/file-storage.service.spec.ts
Normal file
18
backend/src/common/file-storage/file-storage.service.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
|
||||
describe('FileStorageService', () => {
|
||||
let service: FileStorageService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FileStorageService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FileStorageService>(FileStorageService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
128
backend/src/common/file-storage/file-storage.service.ts
Normal file
128
backend/src/common/file-storage/file-storage.service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Attachment } from './entities/attachment.entity.js';
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
private readonly logger = new Logger(FileStorageService.name);
|
||||
private readonly uploadRoot: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Attachment)
|
||||
private attachmentRepository: Repository<Attachment>,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
// ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local
|
||||
this.uploadRoot =
|
||||
this.configService.get('NODE_ENV') === 'production'
|
||||
? '/share/dms-data'
|
||||
: path.join(process.cwd(), 'uploads');
|
||||
|
||||
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
|
||||
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Upload (บันทึกไฟล์ลง Temp)
|
||||
*/
|
||||
async upload(file: Express.Multer.File, userId: number): Promise<Attachment> {
|
||||
const tempId = uuidv4();
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const storedFilename = `${uuidv4()}${fileExt}`;
|
||||
const tempPath = path.join(this.uploadRoot, 'temp', storedFilename);
|
||||
|
||||
// 1. คำนวณ Checksum (SHA-256) เพื่อความปลอดภัยและความถูกต้องของไฟล์
|
||||
const checksum = this.calculateChecksum(file.buffer);
|
||||
|
||||
// 2. บันทึกไฟล์ลง Disk (Temp Folder)
|
||||
try {
|
||||
await fs.writeFile(tempPath, file.buffer);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write file: ${tempPath}`, error);
|
||||
throw new BadRequestException('File upload failed');
|
||||
}
|
||||
|
||||
// 3. สร้าง Record ใน Database
|
||||
const attachment = this.attachmentRepository.create({
|
||||
originalFilename: file.originalname,
|
||||
storedFilename: storedFilename,
|
||||
filePath: tempPath, // เก็บ path ปัจจุบันไปก่อน
|
||||
mimeType: file.mimetype,
|
||||
fileSize: file.size,
|
||||
isTemporary: true,
|
||||
tempId: tempId,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม.
|
||||
checksum: checksum,
|
||||
uploadedByUserId: userId,
|
||||
});
|
||||
|
||||
return this.attachmentRepository.save(attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent)
|
||||
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
|
||||
*/
|
||||
async commit(tempIds: string[]): Promise<Attachment[]> {
|
||||
const attachments = await this.attachmentRepository.find({
|
||||
where: { tempId: In(tempIds), isTemporary: true },
|
||||
});
|
||||
|
||||
if (attachments.length !== tempIds.length) {
|
||||
throw new NotFoundException('Some files not found or already committed');
|
||||
}
|
||||
|
||||
const committedAttachments: Attachment[] = [];
|
||||
const today = new Date();
|
||||
const year = today.getFullYear().toString();
|
||||
const month = (today.getMonth() + 1).toString().padStart(2, '0');
|
||||
|
||||
// โฟลเดอร์ถาวรแยกตาม ปี/เดือน
|
||||
const permanentDir = path.join(this.uploadRoot, 'permanent', year, month);
|
||||
await fs.ensureDir(permanentDir);
|
||||
|
||||
for (const att of attachments) {
|
||||
const oldPath = att.filePath;
|
||||
const newPath = path.join(permanentDir, att.storedFilename);
|
||||
|
||||
try {
|
||||
// ย้ายไฟล์
|
||||
await fs.move(oldPath, newPath, { overwrite: true });
|
||||
|
||||
// อัปเดตข้อมูลใน DB
|
||||
att.filePath = newPath;
|
||||
att.isTemporary = false;
|
||||
att.tempId = undefined; // เคลียร์ tempId
|
||||
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
|
||||
|
||||
committedAttachments.push(await this.attachmentRepository.save(att));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to move file from ${oldPath} to ${newPath}`,
|
||||
error,
|
||||
);
|
||||
// ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ)
|
||||
throw new BadRequestException(
|
||||
`Failed to commit file: ${att.originalFilename}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return committedAttachments;
|
||||
}
|
||||
|
||||
private calculateChecksum(buffer: Buffer): string {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,26 @@ interface JwtPayload {
|
||||
username: string;
|
||||
}
|
||||
|
||||
import { UserService } from '../../modules/user/user.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
constructor(
|
||||
configService: ConfigService,
|
||||
private userService: UserService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
// ใส่ ! เพื่อยืนยันว่ามีค่าแน่นอน (ConfigValidation เช็คให้แล้ว)
|
||||
secretOrKey: configService.get<string>('JWT_SECRET')!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
return { userId: payload.sub, username: payload.username };
|
||||
const user = await this.userService.findOne(payload.sub);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user