diff --git a/.gitignore b/.gitignore index 2813ecc..2c9ec0b 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,5 @@ TODO.txt *.lck *.log -.claude \ No newline at end of file +.claude +*.report.md \ No newline at end of file diff --git a/build.gradle b/build.gradle index 779fd1e..89da676 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,11 @@ dependencies { implementation 'net.bytebuddy:byte-buddy:1.14.5' + // Commons FileUpload (WAS 독립적인 multipart 처리) + implementation 'commons-fileupload:commons-fileupload:1.5' + implementation 'commons-io:commons-io:2.15.1' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' testImplementation 'org.mockito:mockito-inline:3.11.2' diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java index 0325693..21c7141 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalGlobalExceptionHandler.java @@ -7,8 +7,11 @@ import java.util.Map; import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; +import com.eactive.apim.portal.config.PortalProperties; import com.eactive.apim.portal.file.exception.InvalidFileException; +import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; @@ -28,9 +31,11 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; */ @Order(2) @ControllerAdvice(annotations = Controller.class) +@RequiredArgsConstructor public class PortalGlobalExceptionHandler { private final Logger log = LoggerFactory.getLogger(getClass()); + private final PortalProperties portalProperties; @ExceptionHandler(value = NotFoundException.class) public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) { @@ -111,4 +116,15 @@ public class PortalGlobalExceptionHandler { modelAndView.setViewName("redirect:" + request.getRequestURI()); return modelAndView; } + + @ExceptionHandler(value = MaxUploadSizeExceededException.class) + public ModelAndView handleMaxUploadSizeExceededException(HttpServletRequest request, RedirectAttributes redirectAttributes, MaxUploadSizeExceededException ex) { + log.warn("파일 업로드 용량 초과: {}", ex.getMessage()); + String maxSize = portalProperties.getFile().getMaxSize(); + String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다."; + redirectAttributes.addFlashAttribute("error", errorMessage); + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("redirect:" + request.getRequestURI()); + return modelAndView; + } } diff --git a/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java b/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java index c527a60..1b69b4f 100644 --- a/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java +++ b/src/main/java/com/eactive/apim/portal/common/exception/PortalRestExceptionHandler.java @@ -2,20 +2,28 @@ package com.eactive.apim.portal.common.exception; import com.eactive.apim.portal.common.dto.ResponseDTO; +import com.eactive.apim.portal.config.PortalProperties; import com.eactive.apim.portal.portaluser.service.AuthNumberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import javax.servlet.http.HttpServletRequest; +@Slf4j @Order(1) @RestControllerAdvice(annotations = RestController.class) +@RequiredArgsConstructor public class PortalRestExceptionHandler { + private final PortalProperties portalProperties; + @ExceptionHandler(value = IntegrationException.class) public ResponseEntity handleHttpRequestMethodNotSupportedException(HttpServletRequest request, IntegrationException ex) { @@ -51,4 +59,13 @@ public class PortalRestExceptionHandler { ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage()); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } + + @ExceptionHandler(value = MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceededException(HttpServletRequest request, MaxUploadSizeExceededException ex) { + log.warn("파일 업로드 용량 초과 (REST): {}", ex.getMessage()); + String maxSize = portalProperties.getFile().getMaxSize(); + String errorMessage = "파일 크기가 허용된 최대 용량(" + maxSize + ")을 초과했습니다."; + ResponseDTO response = new ResponseDTO(413, "413", errorMessage); + return new ResponseEntity<>(response, HttpStatus.PAYLOAD_TOO_LARGE); + } } diff --git a/src/main/java/com/eactive/apim/portal/config/MultipartConfig.java b/src/main/java/com/eactive/apim/portal/config/MultipartConfig.java new file mode 100644 index 0000000..41a5f27 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/config/MultipartConfig.java @@ -0,0 +1,47 @@ +package com.eactive.apim.portal.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.multipart.commons.CommonsMultipartResolver; + +/** + * WAS(WebLogic, JEUS 등) 독립적인 Multipart 파일 업로드 설정. + *

+ * Spring Boot 내장 Tomcat의 spring.servlet.multipart 설정은 외부 WAS에서 동작하지 않으므로, + * CommonsMultipartResolver를 사용하여 모든 환경에서 일관된 파일 업로드 처리를 보장합니다. + *

+ */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class MultipartConfig { + + private final PortalProperties portalProperties; + + /** + * Commons FileUpload 기반 MultipartResolver 설정. + *

+ * Bean 이름을 "filterMultipartResolver"로 지정하여 + * PortalConfigWebDispatcherServlet의 MultipartFilter와 연동합니다. + *

+ * + * @return CommonsMultipartResolver 인스턴스 + */ + @Bean(name = "filterMultipartResolver") + public CommonsMultipartResolver filterMultipartResolver() { + CommonsMultipartResolver resolver = new CommonsMultipartResolver(); + + long maxSizeBytes = portalProperties.getFile().getMaxSizeBytes(); + resolver.setMaxUploadSize(maxSizeBytes); + resolver.setMaxUploadSizePerFile(maxSizeBytes); + resolver.setMaxInMemorySize((int) Math.min(maxSizeBytes, Integer.MAX_VALUE)); + resolver.setDefaultEncoding("UTF-8"); + + log.info("CommonsMultipartResolver configured with maxUploadSize: {} bytes ({})", + maxSizeBytes, portalProperties.getFile().getMaxSize()); + + return resolver; + } +} diff --git a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java index 51beca9..148f79c 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalProperties.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalProperties.java @@ -26,4 +26,31 @@ public class PortalProperties { private String authVirtualCode = ""; private Map> portalSecurity; + + private FileProperties file = new FileProperties(); + + /** + * 파일 업로드 관련 설정 + */ + @Data + public static class FileProperties { + private String maxSize = "8MB"; + private String allowedExtensions = "pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png"; + + /** + * maxSize 문자열을 bytes로 변환 + * "8MB" -> 8388608, "10MB" -> 10485760 + */ + public long getMaxSizeBytes() { + String size = maxSize.toUpperCase().trim(); + if (size.endsWith("MB")) { + return Long.parseLong(size.replace("MB", "").trim()) * 1024 * 1024; + } else if (size.endsWith("KB")) { + return Long.parseLong(size.replace("KB", "").trim()) * 1024; + } else if (size.endsWith("GB")) { + return Long.parseLong(size.replace("GB", "").trim()) * 1024 * 1024 * 1024; + } + return Long.parseLong(size); // bytes로 간주 + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1bced55..dfbc54b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,11 +40,6 @@ spring: cache: false main: allow-bean-definition-overriding: true - servlet: - multipart: - max-file-size: 8MB - max-request-size: 8MB - security: basic: enabled: false @@ -71,6 +66,10 @@ gateway: entity-package: com.eactive.eai.data.entity.onl portal: + file: + max-size: 10MB + allowed-extensions: pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png + logging: log-path: /Log/App/eapim/ diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 1a2a3dd..f423d22 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -64,6 +64,86 @@ $(function(){ }); }); +/** + * 파일 검증 유틸리티 + * PORTAL_CONFIG.file 설정값 사용 + */ +const FileValidator = { + /** + * 파일 크기 검증 + * @param {File} file - 검증할 파일 + * @returns {boolean} 유효 여부 + */ + validateFileSize: function(file) { + const maxSizeBytes = window.PORTAL_CONFIG?.file?.maxSizeBytes || 8388608; + if (file.size > maxSizeBytes) { + const maxSize = window.PORTAL_CONFIG?.file?.maxSize || '8MB'; + customPopups.showAlert('파일 크기는 ' + maxSize + '를 초과할 수 없습니다.\n선택한 파일: ' + this.formatFileSize(file.size)); + return false; + } + return true; + }, + + /** + * 파일 확장자 검증 + * @param {File} file - 검증할 파일 + * @returns {boolean} 유효 여부 + */ + validateFileExtension: function(file) { + const allowedExtensions = (window.PORTAL_CONFIG?.file?.allowedExtensions || '').split(','); + const fileExt = file.name.split('.').pop().toLowerCase(); + if (!allowedExtensions.includes(fileExt)) { + customPopups.showAlert('허용되지 않는 파일 형식입니다.\n허용된 형식: ' + allowedExtensions.join(', ')); + return false; + } + return true; + }, + + /** + * 파일 전체 검증 (크기 + 확장자) + * @param {File} file - 검증할 파일 + * @returns {boolean} 유효 여부 + */ + validateFile: function(file) { + return this.validateFileExtension(file) && this.validateFileSize(file); + }, + + /** + * 여러 파일 검증 + * @param {FileList} files - 검증할 파일 목록 + * @returns {boolean} 모두 유효 여부 + */ + validateFiles: function(files) { + for (let i = 0; i < files.length; i++) { + if (!this.validateFile(files[i])) { + return false; + } + } + return true; + }, + + /** + * 바이트를 읽기 쉬운 형식으로 변환 + * @param {number} bytes + * @returns {string} + */ + formatFileSize: function(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + /** + * 최대 허용 용량 반환 (표시용) + * @returns {string} + */ + getMaxSizeDisplay: function() { + return window.PORTAL_CONFIG?.file?.maxSize || '8MB'; + } +}; + class FormValidator { constructor(selector, validateFunction, message, extraValidation) { this.form = document.querySelector(selector); diff --git a/src/main/resources/templates/views/apps/community/mainInquiryForm.html b/src/main/resources/templates/views/apps/community/mainInquiryForm.html index eceb301..d8c6b5b 100644 --- a/src/main/resources/templates/views/apps/community/mainInquiryForm.html +++ b/src/main/resources/templates/views/apps/community/mainInquiryForm.html @@ -62,7 +62,7 @@
-

첨부파일은 10MB 이내로 등록해 주세요.

+

첨부파일은 8MB 이내로 등록해 주세요.

문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)

@@ -123,11 +123,8 @@ const file = fileInput.files[0]; if (file) { - const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png']; - const fileExt = '.' + file.name.split('.').pop().toLowerCase(); - - if (!allowedExtensions.includes(fileExt)) { - customPopups.showAlert("허용된 파일 형식이 아닙니다.\n문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다."); + // FileValidator를 사용한 파일 검증 + if (!FileValidator.validateFile(file)) { fileInput.value = ''; fileNameInput.value = ''; fileNameInput.classList.remove('i_trash', 'after'); diff --git a/src/main/resources/templates/views/apps/community/mainPartnershipForm.html b/src/main/resources/templates/views/apps/community/mainPartnershipForm.html index 96c0272..1823019 100644 --- a/src/main/resources/templates/views/apps/community/mainPartnershipForm.html +++ b/src/main/resources/templates/views/apps/community/mainPartnershipForm.html @@ -61,7 +61,7 @@
-

첨부파일은 10MB 이내로 등록해 주세요.

+

첨부파일은 8MB 이내로 등록해 주세요.

문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)

@@ -127,32 +127,15 @@ const fileInput = document.getElementById('file'); const fileNameInput = document.querySelector('.upload-name'); - const allowFileExtention = ['pdf','doc','docx','xls','xlsx','ppt','pptx','hwp','gif','jpg','png']; - - function isValidFileExtention(filename) { - const extention = filename.split('.').pop().toLowerCase(); - return allowFileExtention.includes(extention); - } - - function isValidFileSize(filesize) { - const maxSize = 10 * 1024 * 1024; - return filesize <= maxSize; - - } - fileInput.addEventListener('change', function () { - const file = fileInput.files[0]; if (file) { - if(!isValidFileExtention(file.name)) { - const errorMsg = '허용된 파일 형식이 아닙니다.
문서파일과 이미지 파일만 등록 가능합니다.
(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)'; - const formattedMsg = errorMsg.replace(/\n/g,'
'); - $('#customAlertMessage').html(formattedMsg); - $('#customAlert').css('display','flex'); - return; - } - if(!isValidFileSize(file.size)) { - customPopups.showAlert('첨부파일은 10MB 이내로 등록해 주세요.') + // FileValidator를 사용한 파일 검증 + if (!FileValidator.validateFile(file)) { + fileInput.value = ''; + fileNameInput.value = '첨부파일을 등록 해 주세요'; + fileNameInput.classList.remove('i_trash', 'after'); + fileNameInput.classList.add('before'); return; } fileNameInput.value = file.name; diff --git a/src/main/resources/templates/views/apps/register/components/orgBasicInfoForm.html b/src/main/resources/templates/views/apps/register/components/orgBasicInfoForm.html index 9b38082..e2f08d1 100644 --- a/src/main/resources/templates/views/apps/register/components/orgBasicInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/orgBasicInfoForm.html @@ -265,7 +265,7 @@
-

첨부파일은 10MB 이내로 등록해 주세요.

+

첨부파일은 8MB 이내로 등록해 주세요.

문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)

@@ -354,28 +354,18 @@ fileInput.addEventListener('change', function() { const files = fileInput.files; if (files.length > 0) { - let totalSize = 0; - let fileNames = []; - const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'gif', 'jpg', 'png']; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - totalSize += file.size; - const fileExtension = file.name.split('.').pop().toLowerCase(); - - if (!allowedExtensions.includes(fileExtension)) { - customPopups.showAlert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다.`); - fileInput.value = ''; - return; - } - - fileNames.push(file.name); + // FileValidator를 사용한 파일 검증 + if (!FileValidator.validateFiles(files)) { + fileInput.value = ''; + fileNameInput.value = ''; + fileNameInput.classList.remove('i_trash', 'after'); + fileNameInput.classList.add('before'); + return; } - if (totalSize > 10 * 1024 * 1024) { // 10MB - customPopups.showAlert('총 파일 크기는 10MB를 초과할 수 없습니다.'); - fileInput.value = ''; - return; + let fileNames = []; + for (let i = 0; i < files.length; i++) { + fileNames.push(files[i].name); } fileNameInput.value = fileNames.join(', '); diff --git a/src/main/resources/templates/views/fragment/kbank/head.html b/src/main/resources/templates/views/fragment/kbank/head.html index 6a795a7..efc7904 100644 --- a/src/main/resources/templates/views/fragment/kbank/head.html +++ b/src/main/resources/templates/views/fragment/kbank/head.html @@ -35,6 +35,18 @@ + + + +