diff --git a/build.gradle b/build.gradle index 5d38b838..adafc73b 100644 --- a/build.gradle +++ b/build.gradle @@ -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') { diff --git a/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java b/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java index be475dd8..f91bb735 100644 --- a/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java +++ b/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java @@ -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; @@ -37,12 +38,23 @@ public class ManagementTaskController { @Secured({"ROLE_MANAGER", "ROLE_USER"}) public ResponseEntity createTask( @RequestPart(name = "taskInfo") @Valid CreateTaskRequest createTaskRequest, - @RequestPart(name = "attachment", required = false) List attachments, + @Schema(description = "파일은 5개 이하만 업로드 가능합니다.") @RequestPart(name = "attachment", required = false) List 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 createTaskWithScanner( + @RequestPart(name = "taskInfo") @Valid CreateTaskRequest createTaskRequest, + @Schema(description = "파일은 5개 이하만 업로드 가능합니다.") @RequestPart(name = "attachment", required = false) List 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"}) diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/ClamAVScanner.java b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/ClamAVScanner.java new file mode 100644 index 00000000..64d3b6bd --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/ClamAVScanner.java @@ -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 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); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScanner.java b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScanner.java new file mode 100644 index 00000000..40e38420 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScanner.java @@ -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 scanFiles(List files) { + List> 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 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); + } + } + } + }); + } +} diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScannerPort.java b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScannerPort.java new file mode 100644 index 00000000..b5d5490d --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/clamav/FileVirusScannerPort.java @@ -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 scanFiles(List files); + MultipartFile scanSingleFile(MultipartFile file) throws ExecutionException, InterruptedException; +} diff --git a/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java b/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java index 385c579d..e6c07959 100644 --- a/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java @@ -8,4 +8,6 @@ public interface CreateTaskUsecase { CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createTaskRequest, List files); + + CreateTaskResponse createTaskWithScanner(Long requesterId, CreateTaskRequest createTaskRequest, List files); } diff --git a/src/main/java/clap/server/application/service/task/CreateTaskService.java b/src/main/java/clap/server/application/service/task/CreateTaskService.java index 3601aed0..d8a55567 100644 --- a/src/main/java/clap/server/application/service/task/CreateTaskService.java +++ b/src/main/java/clap/server/application/service/task/CreateTaskService.java @@ -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; @@ -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; @@ -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 @@ -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 files) { + Member member = memberService.findActiveMember(requesterId); + Category category = categoryService.findById(createTaskRequest.categoryId()); + Task task = Task.createTask(member, category, createTaskRequest.title(), createTaskRequest.description()); + List 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 files, Task task) { - List fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_IMAGE, files); + List fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_FILE, files); List attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls); commandAttachmentPort.saveAll(attachments); } diff --git a/src/main/java/clap/server/application/service/task/UpdateTaskService.java b/src/main/java/clap/server/application/service/task/UpdateTaskService.java index 358ed8cf..33c23113 100644 --- a/src/main/java/clap/server/application/service/task/UpdateTaskService.java +++ b/src/main/java/clap/server/application/service/task/UpdateTaskService.java @@ -125,7 +125,7 @@ private void updateAttachments(List attachmentIdsToDelete, List fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_IMAGE, files); + List fileUrls = s3UploadPort.uploadFiles(FilePathPolicy.TASK_FILE, files); List attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls); commandAttachmentPort.saveAll(attachments); } diff --git a/src/main/java/clap/server/application/service/webhook/SendNotificationService.java b/src/main/java/clap/server/application/service/webhook/SendNotificationService.java index 473a37b5..ef0c842f 100644 --- a/src/main/java/clap/server/application/service/webhook/SendNotificationService.java +++ b/src/main/java/clap/server/application/service/webhook/SendNotificationService.java @@ -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; @@ -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( diff --git a/src/main/java/clap/server/config/async/AsyncConfig.java b/src/main/java/clap/server/config/async/AsyncConfig.java index 8671f0eb..46f7edd4 100644 --- a/src/main/java/clap/server/config/async/AsyncConfig.java +++ b/src/main/java/clap/server/config/async/AsyncConfig.java @@ -8,8 +8,6 @@ @Configuration @EnableAsync public class AsyncConfig { - - @Bean(name = "notificationExecutor") public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); @@ -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; + } } diff --git a/src/main/java/clap/server/config/clamav/ClamAVConfig.java b/src/main/java/clap/server/config/clamav/ClamAVConfig.java new file mode 100644 index 00000000..fe59e087 --- /dev/null +++ b/src/main/java/clap/server/config/clamav/ClamAVConfig.java @@ -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); + } +} diff --git a/src/main/java/clap/server/domain/policy/attachment/FilePathPolicy.java b/src/main/java/clap/server/domain/policy/attachment/FilePathPolicy.java index 8d15a9b9..26959572 100644 --- a/src/main/java/clap/server/domain/policy/attachment/FilePathPolicy.java +++ b/src/main/java/clap/server/domain/policy/attachment/FilePathPolicy.java @@ -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"), ; diff --git a/src/main/java/clap/server/exception/ClamAVException.java b/src/main/java/clap/server/exception/ClamAVException.java new file mode 100644 index 00000000..b0c54844 --- /dev/null +++ b/src/main/java/clap/server/exception/ClamAVException.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/exception/code/FileErrorcode.java b/src/main/java/clap/server/exception/code/FileErrorcode.java index dae4869f..3d193292 100644 --- a/src/main/java/clap/server/exception/code/FileErrorcode.java +++ b/src/main/java/clap/server/exception/code/FileErrorcode.java @@ -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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d8331c82..1bc18d13 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,7 @@ spring: - elasticsearch.yml - s3.yml - notifications.yml + - clamav.yml application: name: taskflow web.resources.add-mappings: false @@ -34,19 +35,20 @@ web: local: ${TASKFLOW_LOCAL_WEB:127.0.0.1:5173} service: ${TASKFLOW_SERVICE_WEB:127.0.0.1:5173} -#logging: -# level: -# root: INFO -# taskflow.clap.server: ERROR +logging: + level: + root: INFO + taskflow.clap.server: DEBUG # org: # springframework: DEBUG + --- spring.config.activate.on-profile: local logging: level: root: INFO - taskflow.clap.server: ERROR + taskflow.clap.server: DEBUG # org: # springframework: DEBUG diff --git a/src/main/resources/clamav.yml b/src/main/resources/clamav.yml new file mode 100644 index 00000000..09232cfc --- /dev/null +++ b/src/main/resources/clamav.yml @@ -0,0 +1,3 @@ +clamav: + host: localhost + port: 3310 \ No newline at end of file