yona#37 파일 업로드 클라이언트, 서버단 사전 검증으로 적용
This commit is contained in:
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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<ResponseDTO> 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<ResponseDTO> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 파일 업로드 설정.
|
||||
* <p>
|
||||
* Spring Boot 내장 Tomcat의 spring.servlet.multipart 설정은 외부 WAS에서 동작하지 않으므로,
|
||||
* CommonsMultipartResolver를 사용하여 모든 환경에서 일관된 파일 업로드 처리를 보장합니다.
|
||||
* </p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class MultipartConfig {
|
||||
|
||||
private final PortalProperties portalProperties;
|
||||
|
||||
/**
|
||||
* Commons FileUpload 기반 MultipartResolver 설정.
|
||||
* <p>
|
||||
* Bean 이름을 "filterMultipartResolver"로 지정하여
|
||||
* PortalConfigWebDispatcherServlet의 MultipartFilter와 연동합니다.
|
||||
* </p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,31 @@ public class PortalProperties {
|
||||
private String authVirtualCode = "";
|
||||
|
||||
private Map<RoleCode, List<String>> 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로 간주
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<div class="list-dot">
|
||||
<p>첨부파일은 10MB 이내로 등록해 주세요.</p>
|
||||
<p th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요.'">첨부파일은 8MB 이내로 등록해 주세요.</p>
|
||||
<p>문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg,
|
||||
png)</p>
|
||||
</div>
|
||||
@@ -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');
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
<div class="list-dot">
|
||||
<p>첨부파일은 10MB 이내로 등록해 주세요.</p>
|
||||
<p th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요.'">첨부파일은 8MB 이내로 등록해 주세요.</p>
|
||||
<p>문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg,
|
||||
png)</p>
|
||||
</div>
|
||||
@@ -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 = '허용된 파일 형식이 아닙니다. <br>문서파일과 이미지 파일만 등록 가능합니다. <br> (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)';
|
||||
const formattedMsg = errorMsg.replace(/\n/g,'<br>');
|
||||
$('#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;
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
<span th:errors="*{compRegFile}"></span>
|
||||
</div>
|
||||
<div class="list-dot">
|
||||
<p>첨부파일은 10MB 이내로 등록해 주세요.</p>
|
||||
<p th:text="'첨부파일은 ' + ${@portalProperties.file.maxSize} + ' 이내로 등록해 주세요.'">첨부파일은 8MB 이내로 등록해 주세요.</p>
|
||||
<p>문서파일과 이미지 파일만 등록 가능합니다. (pdf, doc, docx, xls, xlsx, ppt, pptx, hwp, gif, jpg, png)</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(', ');
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
|
||||
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
|
||||
<script th:src="@{/js/lodash.js}"></script>
|
||||
|
||||
<!-- Portal 설정값 전역 노출 -->
|
||||
<script th:inline="javascript">
|
||||
window.PORTAL_CONFIG = {
|
||||
file: {
|
||||
maxSize: /*[[${@portalProperties.file.maxSize}]]*/ '8MB',
|
||||
maxSizeBytes: /*[[${@portalProperties.file.maxSizeBytes}]]*/ 8388608,
|
||||
allowedExtensions: /*[[${@portalProperties.file.allowedExtensions}]]*/ 'pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script th:src="@{/js/common.js}"></script>
|
||||
<script th:src="@{/js/moment.min.js}"></script>
|
||||
<script th:src="@{/js/daterangepicker.js}"></script>
|
||||
|
||||
Reference in New Issue
Block a user