Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/mjsec/lms/controller/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/mjsec/lms/domain/AssignmentSubmission.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

import java.time.ZonedDateTime;

@Entity
@Getter
@Setter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.Data;

import java.time.LocalDateTime;
import java.time.ZonedDateTime;

@Data
@Builder
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/mjsec/lms/dto/SubmissionResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.Data;

import java.time.LocalDateTime;
import java.time.ZonedDateTime;

//과제 제출 반환 Dto
@Data
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/mjsec/lms/service/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());

Expand Down Expand Up @@ -243,7 +243,7 @@ public List<SubmissionResponse> 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);
Expand Down
230 changes: 165 additions & 65 deletions src/main/java/com/mjsec/lms/service/FileService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -37,6 +46,7 @@ public class FileService {
"image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"
);


// 스프링 빈 생성 후 실행되는 초기화 메서드
@PostConstruct
public void init() {
Expand All @@ -52,32 +62,8 @@ public void init() {

// 다중 이미지 업로드 메서드
public List<String> uploadMultipleImages(List<MultipartFile> 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<String> 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);
}

// 다중 이미지 업데이트
Expand Down Expand Up @@ -123,6 +109,7 @@ public List<String> updateMultipleImages(List<String> currentImageUrls, List<Mul

// 다중 이미지 삭제 메서드
public void deleteMultipleImages(List<String> imageUrls) {

if (imageUrls == null || imageUrls.isEmpty()) {
log.debug("No images to delete");
return;
Expand All @@ -141,22 +128,74 @@ public void deleteMultipleImages(List<String> imageUrls) {
log.info("Deleted {}/{} images successfully", deletedCount, imageUrls.size());
}

// 다중 파일 업로드 시 메모리 사용량 관리
public List<String> uploadMultipleImagesMemorySafe(List<MultipartFile> 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<String> 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) {
Expand Down Expand Up @@ -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에서 파일명 추출
Expand All @@ -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; // 메모리 체크 실패 시 업로드 허용 (안전장치)
}
}

}
1 change: 1 addition & 0 deletions src/main/java/com/mjsec/lms/type/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading