Skip to content
Closed
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ dependencies {
implementation 'org.apache.tika:tika-core:2.9.0'
implementation 'org.apache.tika:tika-parsers:2.9.0'

// Clam AV
implementation group: 'xyz.capybara', name: 'clamav-client', version: '2.1.2'


}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import clap.server.adapter.inbound.web.dto.task.request.CreateTaskRequest;
import clap.server.adapter.inbound.web.dto.task.request.UpdateTaskRequest;
import clap.server.adapter.inbound.web.dto.task.response.CreateTaskResponse;
import clap.server.adapter.inbound.web.dto.task.response.UpdateTaskResponse;
import clap.server.application.port.inbound.task.*;
import clap.server.application.port.inbound.task.CreateTaskUsecase;
import clap.server.application.port.inbound.task.UpdateTaskUsecase;
import clap.server.common.annotation.architecture.WebAdapter;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
Expand Down Expand Up @@ -37,12 +38,23 @@ public class ManagementTaskController {
@Secured({"ROLE_MANAGER", "ROLE_USER"})
public ResponseEntity<CreateTaskResponse> createTask(
@RequestPart(name = "taskInfo") @Valid CreateTaskRequest createTaskRequest,
@RequestPart(name = "attachment", required = false) List<MultipartFile> attachments,
@Schema(description = "파일은 5개 이하만 업로드 가능합니다.") @RequestPart(name = "attachment", required = false) List<MultipartFile> attachments,
@AuthenticationPrincipal SecurityUserDetails userInfo
){
return ResponseEntity.ok(createTaskUsecase.createTask(userInfo.getUserId(), createTaskRequest, attachments));
}

@Operation(summary = "작업 요청 생성, 파일 스캔 기능 추가")
@PostMapping(value = "/v2", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
@Secured({"ROLE_MANAGER", "ROLE_USER"})
public ResponseEntity<CreateTaskResponse> createTaskWithScanner(
@RequestPart(name = "taskInfo") @Valid CreateTaskRequest createTaskRequest,
@Schema(description = "파일은 5개 이하만 업로드 가능합니다.") @RequestPart(name = "attachment", required = false) List<MultipartFile> attachments,
@AuthenticationPrincipal SecurityUserDetails userInfo
){
return ResponseEntity.ok(createTaskUsecase.createTaskWithScanner(userInfo.getUserId(), createTaskRequest, attachments));
}

@Operation(summary = "작업 수정")
@PatchMapping(value = "/{taskId}", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE})
@Secured({"ROLE_MANAGER", "ROLE_USER"})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package clap.server.adapter.outbound.infrastructure.clamav;

import clap.server.exception.ClamAVException;
import clap.server.exception.code.FileErrorcode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import xyz.capybara.clamav.ClamavClient;
import xyz.capybara.clamav.ClamavException;
import xyz.capybara.clamav.commands.scan.result.ScanResult;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

@Slf4j
@Component
@RequiredArgsConstructor
public class ClamAVScanner {
private final ClamavClient clamavClient;
private final ThreadPoolTaskExecutor clamavExecutor;

public CompletableFuture<Void> scanFileAsync(String filePath) {
return CompletableFuture.supplyAsync(() -> {
Path originalPath = Paths.get(filePath);
Path tempPath = null;
try {
String originalFileName = originalPath.getFileName().toString();
String fileExtension = getFileExtension(originalFileName);
String tempFileName = "scan_" + UUID.randomUUID() + fileExtension;
tempPath = Files.createTempFile("scan_", tempFileName);

Files.copy(originalPath, tempPath, StandardCopyOption.REPLACE_EXISTING);

ScanResult result = clamavClient.scan(tempPath);
if (result instanceof ScanResult.OK) {
log.info("파일이 안전합니다: {}", originalFileName);
} else if (result instanceof ScanResult.VirusFound virusFound) {
log.warn("바이러스 발견: {} in {}", virusFound.getFoundViruses(), originalFileName);
throw new ClamAVException(FileErrorcode.VIRUS_FILE_DETECTED);
} else {
log.warn("알 수 없는 스캔 결과 타입: {}", result.getClass().getName());
throw new ClamAVException(FileErrorcode.FILE_SCAN_FAILED);
}
return null;
} catch (IOException e) {
log.error("파일 처리 중 오류 발생: {}", filePath, e);
throw new ClamavException(e);
} catch (ClamavException e) {
log.error("ClamAV 스캔 중 오류 발생: {}", filePath, e);
throw new ClamavException(e);
} finally {
if (tempPath != null) {
try {
Files.deleteIfExists(tempPath);
} catch (IOException e) {
log.warn("임시 파일 삭제 실패: {}", tempPath, e);
}
}
}
}, clamavExecutor);
}

private String getFileExtension(String fileName) {
int lastIndexOf = fileName.lastIndexOf(".");
if (lastIndexOf == -1) {
return ""; // 확장자가 없는 경우
}
return fileName.substring(lastIndexOf);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package clap.server.adapter.outbound.infrastructure.clamav;

import clap.server.exception.AdapterException;
import clap.server.exception.ClamAVException;
import clap.server.exception.code.FileErrorcode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class FileVirusScanner implements FileVirusScannerPort {
private final ClamAVScanner clamAVScanner;

public List<MultipartFile> scanFiles(List<MultipartFile> files) {
List<CompletableFuture<MultipartFile>> scanResults = files.stream()
.map(this::scanFile)
.toList();

CompletableFuture.allOf(scanResults.toArray(new CompletableFuture[0])).join();

return scanResults.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}

public MultipartFile scanSingleFile(MultipartFile file) throws ExecutionException, InterruptedException {
return scanFile(file).get();
}

@Async("clamavExecutor")
protected CompletableFuture<MultipartFile> scanFile(MultipartFile file) {
return CompletableFuture.supplyAsync(() -> {
Path tempPath = null;
try {
tempPath = Files.createTempFile("scan_", "_" + file.getOriginalFilename());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, tempPath, StandardCopyOption.REPLACE_EXISTING);
}
clamAVScanner.scanFileAsync(tempPath.toString()).get();
return file;
} catch (ClamAVException e) {
log.warn("Virus detected in file: {}", file.getOriginalFilename());
throw new AdapterException(FileErrorcode.FILE_SCAN_FAILED);
} catch (Exception e) {
log.error("Failed to scan file: {}", file.getOriginalFilename(), e);
return null;
} finally {
if (tempPath != null) {
try {
Files.deleteIfExists(tempPath);
} catch (IOException e) {
log.error("Failed to delete temp file: {}", tempPath, e);
}
}
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package clap.server.adapter.outbound.infrastructure.clamav;

import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.concurrent.ExecutionException;

public interface FileVirusScannerPort {
List<MultipartFile> scanFiles(List<MultipartFile> files);
MultipartFile scanSingleFile(MultipartFile file) throws ExecutionException, InterruptedException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@

public interface CreateTaskUsecase {
CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createTaskRequest, List<MultipartFile> files);

CreateTaskResponse createTaskWithScanner(Long requesterId, CreateTaskRequest createTaskRequest, List<MultipartFile> files);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import clap.server.adapter.inbound.web.dto.task.request.CreateTaskRequest;
import clap.server.adapter.inbound.web.dto.task.response.CreateTaskResponse;

import clap.server.adapter.outbound.infrastructure.clamav.FileVirusScannerPort;
import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole;
import clap.server.adapter.outbound.persistense.entity.notification.constant.NotificationType;
import clap.server.application.mapper.AttachmentMapper;
Expand All @@ -15,15 +15,16 @@
import clap.server.application.port.outbound.task.CommandTaskPort;
import clap.server.application.service.webhook.SendNotificationService;
import clap.server.common.annotation.architecture.ApplicationService;
import clap.server.domain.policy.attachment.FilePathPolicy;
import clap.server.domain.model.member.Member;
import clap.server.domain.model.task.Attachment;
import clap.server.domain.model.task.Category;
import clap.server.domain.model.task.Task;
import clap.server.domain.policy.attachment.FilePathPolicy;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;
import java.util.List;


Expand All @@ -37,6 +38,7 @@ public class CreateTaskService implements CreateTaskUsecase {
private final CommandAttachmentPort commandAttachmentPort;
private final S3UploadPort s3UploadPort;
private final SendNotificationService sendNotificationService;
private final FileVirusScannerPort fileVirusScannerPort;

@Override
@Transactional
Expand All @@ -48,14 +50,31 @@ public CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createT
savedTask.setInitialProcessorOrder();
commandTaskPort.save(savedTask);

if (files != null) {
saveAttachments(files, savedTask);}
if (files != null) {saveAttachments(files, savedTask);}
publishNotification(savedTask);
return TaskResponseMapper.toCreateTaskResponse(savedTask);
}

@Override
@Transactional
public CreateTaskResponse createTaskWithScanner(Long requesterId, CreateTaskRequest createTaskRequest, List<MultipartFile> files) {
Member member = memberService.findActiveMember(requesterId);
Category category = categoryService.findById(createTaskRequest.categoryId());
Task task = Task.createTask(member, category, createTaskRequest.title(), createTaskRequest.description());
List<MultipartFile> scannedFiles = (files != null && !files.isEmpty()) ? fileVirusScannerPort.scanFiles(files) : new ArrayList<>();
Task savedTask = commandTaskPort.save(task);
savedTask.setInitialProcessorOrder();
commandTaskPort.save(savedTask);

if (!scannedFiles.isEmpty()) {
saveAttachments(scannedFiles, savedTask);
}
publishNotification(savedTask);
return TaskResponseMapper.toCreateTaskResponse(savedTask);
}

private void saveAttachments(List<MultipartFile> files, Task task) {
List<String> fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_IMAGE, files);
List<String> fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_FILE, files);
List<Attachment> attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls);
commandAttachmentPort.saveAll(attachments);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ private void updateAttachments(List<Long> attachmentIdsToDelete, List<MultipartF
attachmentsToDelete.forEach(Attachment::softDelete);

if (files != null) {
List<String> fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_IMAGE, files);
List<String> fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_FILE, files);
List<Attachment> attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls);
commandAttachmentPort.saveAll(attachments);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import clap.server.domain.model.task.Task;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.CompletableFuture;

Expand Down Expand Up @@ -75,6 +76,7 @@ public void sendPushNotification(Member receiver, NotificationType notificationT
}

@Async("notificationExecutor")
@Transactional
public void sendAgitNotification(NotificationType notificationType,
Task task, String message, String commenterName) {
PushNotificationTemplate pushNotificationTemplate = new PushNotificationTemplate(
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/clap/server/config/async/AsyncConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
@Configuration
@EnableAsync
public class AsyncConfig {


@Bean(name = "notificationExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
Expand All @@ -26,4 +24,16 @@ public ThreadPoolTaskExecutor taskExecutor() {
executor.initialize();
return executor;
}

@Bean(name = "clamavExecutor")
public ThreadPoolTaskExecutor clamavExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("clamav-scan-");
executor.setKeepAliveSeconds(120);
executor.initialize();
return executor;
}
}
20 changes: 20 additions & 0 deletions src/main/java/clap/server/config/clamav/ClamAVConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package clap.server.config.clamav;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import xyz.capybara.clamav.ClamavClient;

@Configuration
public class ClamAVConfig {
@Value("${clamav.host}")
private String host;

@Value("${clamav.port}")
private int port;

@Bean
public ClamavClient clamavClient() {
return new ClamavClient(host, port);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
@Getter
@RequiredArgsConstructor
public enum FilePathPolicy {
TASK_IMAGE("task/image"),
TASK_DOCUMENT("task/docs"),
TASK_FILE("task"),
TASK_COMMENT("task/comments"),
MEMBER_IMAGE("member"),
;
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/clap/server/exception/ClamAVException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package clap.server.exception;

import clap.server.exception.code.BaseErrorCode;

public class ClamAVException extends BaseException {
public ClamAVException(BaseErrorCode code) {
super(code);
}

public BaseErrorCode getErrorCode() {
return (BaseErrorCode)super.getCode();
}
}
5 changes: 4 additions & 1 deletion src/main/java/clap/server/exception/code/FileErrorcode.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
@RequiredArgsConstructor
public enum FileErrorcode implements BaseErrorCode {
FILE_UPLOAD_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_001", "파일 업로드에 실패하였습니다."),
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE_002", "유효하지 않은 파일 유형입니다."),;
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE_002", "유효하지 않은 파일 유형입니다."),
VIRUS_FILE_DETECTED(HttpStatus.BAD_REQUEST, "FILE_003", "안전하지 않은 파일이 감지되었습니다."),
FILE_SCAN_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "파일 스캔에 실패하였습니다.")
;

private final HttpStatus httpStatus;
private final String customCode;
Expand Down
Loading
Loading