diff --git a/build.gradle b/build.gradle index 7424029..849083d 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt:0.12.3' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' implementation 'com.googlecode.json-simple:json-simple:1.1.1' + implementation 'org.apache.tika:tika-core:2.9.1' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/mjsec/lms/controller/AdminController.java b/src/main/java/com/mjsec/lms/controller/AdminController.java index de0905c..27389e8 100644 --- a/src/main/java/com/mjsec/lms/controller/AdminController.java +++ b/src/main/java/com/mjsec/lms/controller/AdminController.java @@ -9,6 +9,8 @@ import com.mjsec.lms.service.AdminService; import com.mjsec.lms.type.ResponseMessage; import jakarta.validation.Valid; + +import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/mjsec/lms/domain/AssignmentSubmission.java b/src/main/java/com/mjsec/lms/domain/AssignmentSubmission.java index 159e915..42cac32 100644 --- a/src/main/java/com/mjsec/lms/domain/AssignmentSubmission.java +++ b/src/main/java/com/mjsec/lms/domain/AssignmentSubmission.java @@ -7,6 +7,8 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; +import java.time.ZonedDateTime; + @Entity @Getter @Setter diff --git a/src/main/java/com/mjsec/lms/dto/DetailSubmissionResponse.java b/src/main/java/com/mjsec/lms/dto/DetailSubmissionResponse.java index c50109b..cb5d211 100644 --- a/src/main/java/com/mjsec/lms/dto/DetailSubmissionResponse.java +++ b/src/main/java/com/mjsec/lms/dto/DetailSubmissionResponse.java @@ -5,6 +5,7 @@ import lombok.Data; import java.time.LocalDateTime; +import java.time.ZonedDateTime; @Data @Builder diff --git a/src/main/java/com/mjsec/lms/dto/SubmissionResponse.java b/src/main/java/com/mjsec/lms/dto/SubmissionResponse.java index 5bdbcfb..4987a7a 100644 --- a/src/main/java/com/mjsec/lms/dto/SubmissionResponse.java +++ b/src/main/java/com/mjsec/lms/dto/SubmissionResponse.java @@ -5,6 +5,7 @@ import lombok.Data; import java.time.LocalDateTime; +import java.time.ZonedDateTime; //과제 제출 반환 Dto @Data diff --git a/src/main/java/com/mjsec/lms/service/AdminService.java b/src/main/java/com/mjsec/lms/service/AdminService.java index 5727104..23ab65f 100644 --- a/src/main/java/com/mjsec/lms/service/AdminService.java +++ b/src/main/java/com/mjsec/lms/service/AdminService.java @@ -30,6 +30,8 @@ import com.mjsec.lms.type.UserRole; import com.mjsec.lms.type.StudyStatus; import jakarta.transaction.Transactional; + +import java.io.IOException; import java.util.List; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/mjsec/lms/service/AssignmentSubmissionService.java b/src/main/java/com/mjsec/lms/service/AssignmentSubmissionService.java index c85b6a2..d6059f9 100644 --- a/src/main/java/com/mjsec/lms/service/AssignmentSubmissionService.java +++ b/src/main/java/com/mjsec/lms/service/AssignmentSubmissionService.java @@ -178,7 +178,7 @@ public void leaveFeedback(Long groupId, Long planId, Long submitId, Long current validationUtils.validateMentorAccess(groupId, currentUserStudentNumber); validationUtils.validatePlanBelongsToGroup(planId, groupId); validationUtils.validateAssignmentSubmissionAllowed(planId); - validationUtils.validateSubmissionStatus(dto.getStatus()); + validationUtils.validateSubmissionStatus(dto.getStatus().toString()); //피드백 내용 + 제출 상태 검증 validationUtils.validateFeedbackContentAndStatus(dto.getFeedback(), dto.getStatus()); @@ -201,7 +201,7 @@ public SubmissionFeedbackDto updateFeedback(Long groupId, Long planId, Long subm validationUtils.validateMentorAccess(groupId, currentUserStudentNumber); validationUtils.validatePlanBelongsToGroup(planId, groupId); validationUtils.validateAssignmentSubmissionAllowed(planId); - validationUtils.validateSubmissionStatus(dto.getStatus()); + validationUtils.validateSubmissionStatus(dto.getStatus().toString()); validationUtils.validateFeedbackContentAndStatus(dto.getFeedback(), dto.getStatus()); @@ -243,7 +243,7 @@ public List getSubmissionsByStatus(Long groupId, Long planId validationUtils.validatePlanBelongsToGroup(planId, groupId); Plan plan = validationUtils.validatePlan(planId); validationUtils.validateAssignmentSubmissionAllowed(planId); - validationUtils.validateSubmissionStatus(status); + validationUtils.validateSubmissionStatus(status.toString()); // 역할 반한 GroupMemberRole role = validationUtils.validateUserRole(user.getUserId(), groupId); diff --git a/src/main/java/com/mjsec/lms/service/FileService.java b/src/main/java/com/mjsec/lms/service/FileService.java index 9751b75..cc98b58 100644 --- a/src/main/java/com/mjsec/lms/service/FileService.java +++ b/src/main/java/com/mjsec/lms/service/FileService.java @@ -2,27 +2,36 @@ import com.mjsec.lms.exception.RestApiException; import com.mjsec.lms.type.ErrorCode; +import com.mjsec.lms.util.FileUtils; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; +import org.apache.tika.Tika; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; +import java.util.*; @Service @Slf4j public class FileService { - // application.yml에서 파일 업로드 경로 설정값을 가져옴 (기본값: uploads/) + private static final int MAX_HEADER_SIZE = 1024; // 헤더 검증용 최대 크기 + private static final double MAX_MEMORY_USAGE_RATIO = 0.8; // 최대 메모리 사용률 80% + + private final MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + + // 파일 업로드 경로 설정값을 가져옴 @Value("${file.upload.path:uploads/}") private String uploadPath; @@ -37,6 +46,7 @@ public class FileService { "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp" ); + // 스프링 빈 생성 후 실행되는 초기화 메서드 @PostConstruct public void init() { @@ -52,32 +62,8 @@ public void init() { // 다중 이미지 업로드 메서드 public List uploadMultipleImages(List files) { - if (files == null || files.isEmpty()) { - log.debug("No files provided for upload"); - return new ArrayList<>(); - } - - // 이미지 개수 제한 검증 - if (files.size() > MAX_IMAGE_COUNT) { - log.warn("Too many images provided: {} (max: {})", files.size(), MAX_IMAGE_COUNT); - throw new RestApiException(ErrorCode.TOO_MANY_IMAGES); - } - - List uploadedUrls = new ArrayList<>(); - for (MultipartFile file : files) { - // 빈 파일 스킵 - if (file.isEmpty()) { - log.debug("Skipping empty file"); - continue; - } - - String uploadedUrl = uploadImage(file); - uploadedUrls.add(uploadedUrl); - } - - log.info("Successfully uploaded {} images", uploadedUrls.size()); - return uploadedUrls; + return uploadMultipleImagesMemorySafe(files); } // 다중 이미지 업데이트 @@ -123,6 +109,7 @@ public List updateMultipleImages(List currentImageUrls, List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { log.debug("No images to delete"); return; @@ -141,22 +128,74 @@ public void deleteMultipleImages(List imageUrls) { log.info("Deleted {}/{} images successfully", deletedCount, imageUrls.size()); } + // 다중 파일 업로드 시 메모리 사용량 관리 + public List uploadMultipleImagesMemorySafe(List files) { + + if (files == null || files.isEmpty()) { + log.debug("No files provided for upload"); + return new ArrayList<>(); + } + + // 이미지 개수 제한 검증 + if (files.size() > MAX_IMAGE_COUNT) { + log.warn("Too many images provided: {} (max: {})", files.size(), MAX_IMAGE_COUNT); + throw new RestApiException(ErrorCode.TOO_MANY_IMAGES); + } + + // 전체 파일 크기 계산 + long totalFileSize = files.stream().mapToLong(MultipartFile::getSize).sum(); + + // 전체 파일에 대한 메모리 체크 + if (!checkMemoryAvailability(totalFileSize)) { + log.error("Insufficient memory for multiple file upload. Total size: {} bytes", totalFileSize); + throw new RestApiException(ErrorCode.INSUFFICIENT_MEMORY); + } + + List uploadedUrls = new ArrayList<>(); + + for (MultipartFile file : files) { + // 빈 파일 스킵 + if (file.isEmpty()) { + log.debug("Skipping empty file"); + continue; + } + + String uploadedUrl = uploadImage(file); + uploadedUrls.add(uploadedUrl); + + // 각 파일 업로드 후 메모리 상태 체크 + if (log.isDebugEnabled()) { + long usedMemory = memoryBean.getHeapMemoryUsage().getUsed(); + long maxMemory = memoryBean.getHeapMemoryUsage().getMax(); + double usageRatio = (double) usedMemory / maxMemory; + log.debug("파일 업로드 후 메모리 상태: {:.2f}%", usageRatio * 100); + } + } + + log.info("Successfully uploaded {} images with total size: {} bytes", + uploadedUrls.size(), totalFileSize); + return uploadedUrls; + } + // 이미지 파일을 업로드하고 웹에서 접근 가능한 URL을 반환하는 메서드 public String uploadImage(MultipartFile file) { + // 파일 유효성 검사 수행 - validateImageFile(file); + validateImageFileMemorySafe(file); // 고유한 파일명 생성 - String fileName = generateUniqueFileName(file.getOriginalFilename()); + String fileName = FileUtils.generateUniqueFileName(file.getOriginalFilename()); String filePath = uploadPath + fileName; try { Path targetLocation = Paths.get(filePath); - // 파일을 지정된 경로에 저장 (기존 파일이 있으면 덮어쓰기) - Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING); + + // 스트림 기반 파일 복사 (메모리 효율적) + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING); + } log.info("File uploaded successfully: {}", fileName); - // 웹에서 접근 가능한 URL 형태로 반환 return "/uploads/" + fileName; } catch (IOException e) { @@ -200,38 +239,9 @@ public String updateImage(String currentImageUrl, MultipartFile newImage, String return currentImageUrl; } - // 업로드할 이미지 파일의 유효성을 검사하는 메서드 - private void validateImageFile(MultipartFile file) { - // 빈 파일인지 확인 - if (file.isEmpty()) { - throw new RestApiException(ErrorCode.EMPTY_FILE); - } - - // 파일 크기가 허용 범위를 초과하는지 확인 - if (file.getSize() > maxFileSize) { - throw new RestApiException(ErrorCode.FILE_SIZE_EXCEEDED); - } - - // 파일 타입이 허용되는 이미지 타입인지 확인 - String contentType = file.getContentType(); - if (!allowedImageTypes.contains(contentType)) { - throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); - } - } - - // 원본 파일명을 기반으로 고유한 파일명을 생성하는 메서드 - private String generateUniqueFileName(String originalFileName) { - String extension = ""; - // 원본 파일명에서 확장자 추출 - if (originalFileName != null && originalFileName.contains(".")) { - extension = originalFileName.substring(originalFileName.lastIndexOf(".")); - } - // UUID를 사용하여 고유한 파일명 생성 - return UUID.randomUUID().toString() + extension; - } - // 기존에 업로드된 이미지 파일을 삭제하는 메서드 public void deleteImage(String imageUrl) { + // URL이 유효하고 uploads 경로로 시작하는지 확인 if (imageUrl != null && imageUrl.startsWith("/uploads/")) { // URL에서 파일명 추출 @@ -247,4 +257,94 @@ public void deleteImage(String imageUrl) { } } } + + /* + ------- PRIVATE ---------- + */ + private void validateImageFileMemorySafe(MultipartFile file) { + + // 기본 검증 + if (file.isEmpty()) { + throw new RestApiException(ErrorCode.EMPTY_FILE); + } + + if (file.getSize() > maxFileSize) { + throw new RestApiException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + // 메모리 사용량 체크 + if (!checkMemoryAvailability(file.getSize())) { + log.error("Insufficient memory for file upload. File size: {} bytes", file.getSize()); + throw new RestApiException(ErrorCode.INSUFFICIENT_MEMORY); + } + + // 원본 파일명 검증 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new RestApiException(ErrorCode.INVALID_FILE_NAME); + } + + // 확장자 추출 및 검증 + String extension = FileUtils.extractAndValidateExtension(originalFilename); + + // 스트림 기반 파일 검증 + try (InputStream inputStream = new BufferedInputStream(file.getInputStream())) { + + // 헤더만 읽어서 Magic Number 검증 (메모리 효율적) + byte[] headerBytes = FileUtils.readHeader(inputStream, MAX_HEADER_SIZE); + + // Magic Number 검증 + if (!FileUtils.verifyImageSignature(headerBytes, extension)) { + log.error("File signature mismatch for file: {}. Expected: {}", + originalFilename, extension); + throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); + } + + // MIME 타입 검출 (스트림 기반) + String detectedMimeType = FileUtils.detectMimeTypeFromStream(inputStream, originalFilename); + + // 검출된 MIME 타입 검증 + if (!allowedImageTypes.contains(detectedMimeType)) { + log.error("Detected MIME type {} is not allowed. File: {}", + detectedMimeType, originalFilename); + throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); + } + + // Content-Type과 실제 검출된 타입 비교 + FileUtils.validateContentType(file.getContentType(), detectedMimeType, originalFilename); + + log.info("File validation successful. File: {}, Type: {}, Size: {} bytes", + originalFilename, detectedMimeType, file.getSize()); + + } catch (IOException e) { + log.error("Failed to read file stream: {}", originalFilename, e); + throw new RestApiException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + // 메모리 가용성 체크 + private boolean checkMemoryAvailability(long fileSize) { + + try { + long usedMemory = memoryBean.getHeapMemoryUsage().getUsed(); + long maxMemory = memoryBean.getHeapMemoryUsage().getMax(); + + // 현재 메모리 사용률 계산 + double currentUsageRatio = (double) usedMemory / maxMemory; + + // 파일 처리 후 예상 메모리 사용률 계산 + double projectedUsageRatio = (double) (usedMemory + fileSize) / maxMemory; + + //얘는 차마 영어로 못 적겠다 + log.debug("현재 메모리 사용률: {:.2f}%, 예상 메모리 사용률: {:.2f}%, 최대 허용률: {:.2f}%", + currentUsageRatio * 100, projectedUsageRatio * 100, MAX_MEMORY_USAGE_RATIO * 100); + + return projectedUsageRatio <= MAX_MEMORY_USAGE_RATIO; + + } catch (Exception e) { + log.warn("Failed to check memory availability, allowing upload", e); + return true; // 메모리 체크 실패 시 업로드 허용 (안전장치) + } + } + } \ No newline at end of file diff --git a/src/main/java/com/mjsec/lms/type/ErrorCode.java b/src/main/java/com/mjsec/lms/type/ErrorCode.java index 1c11a29..ba635d0 100644 --- a/src/main/java/com/mjsec/lms/type/ErrorCode.java +++ b/src/main/java/com/mjsec/lms/type/ErrorCode.java @@ -120,6 +120,7 @@ public enum ErrorCode { IMAGE_NOT_READABLE(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 파일을 읽을 수 없습니다."), IMAGE_LOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 로딩 중 오류가 발생했습니다."), TOO_MANY_IMAGES(HttpStatus.BAD_REQUEST, "이미지는 최대 5개까지만 업로드할 수 있습니다."), + INSUFFICIENT_MEMORY(HttpStatus.SERVICE_UNAVAILABLE, "서버 메모리가 부족하여 파일을 처리할 수 없습니다. 잠시 후 다시 시도해주세요."), ; private final HttpStatus status; diff --git a/src/main/java/com/mjsec/lms/util/FileUtils.java b/src/main/java/com/mjsec/lms/util/FileUtils.java new file mode 100644 index 0000000..61c5ae8 --- /dev/null +++ b/src/main/java/com/mjsec/lms/util/FileUtils.java @@ -0,0 +1,205 @@ +package com.mjsec.lms.util; + +import com.mjsec.lms.exception.RestApiException; +import com.mjsec.lms.type.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.apache.tika.Tika; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +@Component +@Slf4j +public class FileUtils { + + private static final int BUFFER_SIZE = 8192; // 8KB 청크 단위 처리 + + // Apache Tika 인스턴스 + private static final Tika tika = new Tika(); + + // 이미지 파일 시그니처 (Magic Numbers) + private static final Map IMAGE_SIGNATURES = new HashMap<>(); + + static { + // JPEG + IMAGE_SIGNATURES.put("jpg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}); + // PNG + IMAGE_SIGNATURES.put("png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}); + // GIF + IMAGE_SIGNATURES.put("gif87", "GIF87a".getBytes()); + IMAGE_SIGNATURES.put("gif89", "GIF89a".getBytes()); + // WebP + IMAGE_SIGNATURES.put("webp_riff", "RIFF".getBytes()); + IMAGE_SIGNATURES.put("webp_sig", "WEBP".getBytes()); + } + + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "webp" + ); + + private static final List DANGEROUS_EXTENSIONS = Arrays.asList( + "jsp", "jspx", "php", "php3", "php4", "php5", "phtml", + "asp", "aspx", "ascx", "ashx", "asmx", + "exe", "dll", "com", "bat", "cmd", "sh", "bash", + "cgi", "pl", "py", "rb", "js", "jar", "war" + ); + + // 원본 파일명을 기반으로 고유한 파일명을 생성하는 메서드 + public static String generateUniqueFileName(String originalFileName) { + + if (originalFileName == null || originalFileName.trim().isEmpty()) { + throw new RestApiException(ErrorCode.INVALID_FILE_NAME); + } + + String extension = extractAndValidateExtension(originalFileName); + + String safeFileName = UUID.randomUUID().toString() + "." + extension; + + if (safeFileName.contains("..") || safeFileName.contains("/") || safeFileName.contains("\\")) { + log.error("Path traversal attempt in generated filename: {}", safeFileName); + throw new RestApiException(ErrorCode.INVALID_FILE_NAME); + } + + log.info("Safe filename generated: {} -> {}", originalFileName, safeFileName); + return safeFileName; + } + + // 파일 시그니처 검증 메서드 + public static boolean verifyImageSignature(byte[] fileBytes, String expectedExtension) { + + if (fileBytes == null || fileBytes.length < 12) { + return false; + } + + expectedExtension = expectedExtension.toLowerCase(); + + try { + // JPEG 검증 + if (expectedExtension.equals("jpg") || expectedExtension.equals("jpeg")) { + return fileBytes[0] == (byte) 0xFF && + fileBytes[1] == (byte) 0xD8 && + fileBytes[2] == (byte) 0xFF; + } + + // PNG 검증 + if (expectedExtension.equals("png")) { + byte[] pngSignature = IMAGE_SIGNATURES.get("png"); + for (int i = 0; i < pngSignature.length; i++) { + if (fileBytes[i] != pngSignature[i]) { + return false; + } + } + return true; + } + + // GIF 검증 + if (expectedExtension.equals("gif")) { + String header = new String(fileBytes, 0, 6); + return header.equals("GIF87a") || header.equals("GIF89a"); + } + + // WebP 검증 + if (expectedExtension.equals("webp")) { + String riff = new String(fileBytes, 0, 4); + String webp = new String(fileBytes, 8, 4); + return riff.equals("RIFF") && webp.equals("WEBP"); + } + + return false; + } catch (Exception e) { + log.error("Error verifying image signature", e); + return false; + } + } + + // 확장자 추출 및 검증 + public static String extractAndValidateExtension(String originalFileName) { + + // 파일명에서 모든 점(.) 위치 검사 + String[] parts = originalFileName.split("\\."); + if (parts.length > 2) { + log.warn("Double extension detected: {}", originalFileName); + throw new RestApiException(ErrorCode.INVALID_FILE_NAME); + } + + // 실제 확장자 추출 + String extension = ""; + int lastDotIndex = originalFileName.lastIndexOf("."); + if (lastDotIndex > 0 && lastDotIndex < originalFileName.length() - 1) { + extension = originalFileName.substring(lastDotIndex + 1).toLowerCase(); + } + + // 확장자가 없거나 빈 경우 거부 + if (extension.isEmpty()) { + log.warn("No extension found in file: {}", originalFileName); + throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); + } + + // 위험한 확장자 블랙리스트 검증 + for (String dangerous : DANGEROUS_EXTENSIONS) { + if (originalFileName.toLowerCase().contains("." + dangerous)) { + log.error("Dangerous extension detected: {}", originalFileName); + throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); + } + } + + // 허용된 확장자 화이트리스트 검증 + if (!ALLOWED_EXTENSIONS.contains(extension)) { + log.warn("Not allowed extension: {}", extension); + throw new RestApiException(ErrorCode.INVALID_FILE_TYPE); + } + + return extension; + } + + // Content-Type 검증 + public static void validateContentType(String declaredContentType, String detectedMimeType, String filename) { + + if (declaredContentType != null && !declaredContentType.equals(detectedMimeType)) { + log.warn("Content-Type mismatch. Declared: {}, Detected: {} for file: {}", + declaredContentType, detectedMimeType, filename); + // 실제 타입이 허용된 이미지면 계속 진행 + } + } + + // 스트림에서 헤더만 읽기 + public static byte[] readHeader(InputStream inputStream, int maxHeaderSize) throws IOException { + + if (!inputStream.markSupported()) { + throw new IOException("Stream does not support mark/reset"); + } + + inputStream.mark(maxHeaderSize); // 스트림 위치 마킹 + + byte[] headerBytes = new byte[maxHeaderSize]; + int bytesRead = inputStream.read(headerBytes); + + inputStream.reset(); // 스트림 위치 리셋 + + if (bytesRead <= 0) { + throw new IOException("Failed to read file header"); + } + + // 실제 읽은 크기만큼만 반환 + byte[] actualHeader = new byte[bytesRead]; + System.arraycopy(headerBytes, 0, actualHeader, 0, bytesRead); + + return actualHeader; + } + + // 스트림 기반 MIME 타입 검출 + public static String detectMimeTypeFromStream(InputStream inputStream, String filename) throws IOException { + + inputStream.mark(BUFFER_SIZE); // 스트림 위치 마킹 + + try { + return tika.detect(inputStream, filename); + } finally { + inputStream.reset(); // 스트림 위치 리셋 + } + } + + +} diff --git a/src/main/java/com/mjsec/lms/util/IpUtils.java b/src/main/java/com/mjsec/lms/util/IpUtils.java index 6c1cd48..3cda1f6 100644 --- a/src/main/java/com/mjsec/lms/util/IpUtils.java +++ b/src/main/java/com/mjsec/lms/util/IpUtils.java @@ -70,7 +70,7 @@ public static boolean isLocalIp(String ip) { "localhost".equalsIgnoreCase(ip); } - // 유틸리티 클래스이므로 인스턴스화 방지 + // 유틸리티 클래스이므로 인스턴스화 방지 <- @Component 금지 private IpUtils() { throw new IllegalStateException("Utility class"); } diff --git a/src/main/java/com/mjsec/lms/util/ValidationUtils.java b/src/main/java/com/mjsec/lms/util/ValidationUtils.java index c25a49d..84531f3 100644 --- a/src/main/java/com/mjsec/lms/util/ValidationUtils.java +++ b/src/main/java/com/mjsec/lms/util/ValidationUtils.java @@ -328,8 +328,7 @@ public void validateStatusTransition(SubmissionStatus currentStatus, SubmissionS } //과제 제출 상태 ENUM 타입 검증 - public SubmissionStatus validateSubmissionStatus(SubmissionStatus status) { - String statusString = status.toString(); + public SubmissionStatus validateSubmissionStatus(String statusString) { if (statusString == null || statusString.trim().isEmpty()) { throw new RestApiException(ErrorCode.INVALID_SUBMISSION_STATUS);