feat: 비디오 스트리밍 기능 안정화
parent
bd5d965402
commit
5d10ef2575
|
|
@ -102,7 +102,7 @@ public class ManageListController {
|
||||||
return "/web/manage/list";
|
return "/web/manage/list";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 지반정보등록 (관리자)
|
// 지반정보등록 (관리자) - 일반 입력자도 여기 로직을 통해 화면이 보여짐.
|
||||||
@RequestMapping(value = "/meta_info.do")
|
@RequestMapping(value = "/meta_info.do")
|
||||||
public String meta_info(@RequestParam HashMap<String, Object> params,
|
public String meta_info(@RequestParam HashMap<String, Object> params,
|
||||||
ModelMap model, HttpServletRequest request,
|
ModelMap model, HttpServletRequest request,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
package geoinfo.videos;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMethod;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.EgovProperties;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/video")
|
||||||
|
public class VideoStreamingController {
|
||||||
|
|
||||||
|
// 동영상 파일이 저장된 기본 경로
|
||||||
|
private final String VIDEO_DIRECTORY = EgovProperties.getProperty("Geoinfo.FilePath") + "videos";
|
||||||
|
|
||||||
|
@RequestMapping(value = "/stream.do", method = RequestMethod.GET)
|
||||||
|
public void streamVideo(@RequestParam("name") String videoName, HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
|
||||||
|
// 1. 경로 및 파일 유효성 검사
|
||||||
|
if (!StringUtils.hasText(videoName)) {
|
||||||
|
sendError(response, "비디오 파일명이 비어있습니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 인코딩된 파일 이름 디코딩
|
||||||
|
String decodedVideoName;
|
||||||
|
try {
|
||||||
|
decodedVideoName = URLDecoder.decode(videoName, "UTF-8");
|
||||||
|
} catch (UnsupportedEncodingException e) {
|
||||||
|
sendError(response, "비디오 파일명 디코딩에 실패했습니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File videoFile = new File(VIDEO_DIRECTORY, decodedVideoName);
|
||||||
|
|
||||||
|
// Path Traversal 공격을 방지하기 위한 보안 코드 추가
|
||||||
|
try {
|
||||||
|
String videoDirPath = new File(VIDEO_DIRECTORY).getCanonicalPath();
|
||||||
|
String requestedFilePath = videoFile.getCanonicalPath();
|
||||||
|
|
||||||
|
// 요청된 파일의 실제 경로가 허용된 비디오 디렉토리 내에 있는지 확인
|
||||||
|
if (!requestedFilePath.startsWith(videoDirPath)) {
|
||||||
|
sendError(response, "잘못된 파일 경로입니다.", HttpServletResponse.SC_BAD_REQUEST);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 경로 처리 중 오류 발생
|
||||||
|
sendError(response, "파일 경로 처리 중 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoFile.exists() || !videoFile.isFile()) {
|
||||||
|
sendError(response, "요청하신 동영상 파일을 찾을 수 없습니다.", HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RandomAccessFile randomFile = null;
|
||||||
|
OutputStream outputStream = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
long rangeStart = 0;
|
||||||
|
long rangeEnd = 0;
|
||||||
|
boolean isRangeRequest = false;
|
||||||
|
|
||||||
|
// 2. Range 헤더 파싱
|
||||||
|
String rangeHeader = request.getHeader("Range");
|
||||||
|
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
|
||||||
|
isRangeRequest = true;
|
||||||
|
rangeHeader = rangeHeader.substring("bytes=".length());
|
||||||
|
String[] ranges = rangeHeader.split("-");
|
||||||
|
try {
|
||||||
|
rangeStart = Long.parseLong(ranges[0]);
|
||||||
|
if (ranges.length > 1) {
|
||||||
|
rangeEnd = Long.parseLong(ranges[1]);
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
// Range 형식이 잘못된 경우 무시하고 전체 전송 시도
|
||||||
|
rangeStart = 0;
|
||||||
|
rangeEnd = 0;
|
||||||
|
isRangeRequest = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long fileLength = videoFile.length();
|
||||||
|
|
||||||
|
// Range 헤더가 없거나 잘못된 경우, rangeEnd를 파일 끝으로 설정
|
||||||
|
if (rangeEnd == 0) {
|
||||||
|
rangeEnd = fileLength - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 응답 헤더 설정
|
||||||
|
String fileName = videoFile.getName();
|
||||||
|
String mimeType = "video/mp4"; // video/webm, video/ogg 등 파일에 맞게 설정
|
||||||
|
|
||||||
|
// 브라우저별 한글 파일명 처리
|
||||||
|
String header = request.getHeader("User-Agent");
|
||||||
|
String encodedFilename;
|
||||||
|
if (header.contains("MSIE") || header.contains("Trident")) { // IE, Edge
|
||||||
|
encodedFilename = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
|
||||||
|
} else { // Chrome, Firefox, Opera
|
||||||
|
encodedFilename = new String(fileName.getBytes("UTF-8"), "ISO-8859-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedFilename + "\"");
|
||||||
|
response.setContentType(mimeType);
|
||||||
|
response.setHeader("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
|
long rangeLength = rangeEnd - rangeStart + 1;
|
||||||
|
|
||||||
|
if (isRangeRequest) {
|
||||||
|
// 스트리밍 (부분 전송)
|
||||||
|
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206
|
||||||
|
response.setHeader("Content-Length", String.valueOf(rangeLength));
|
||||||
|
response.setHeader("Content-Range", "bytes " + rangeStart + "-" + rangeEnd + "/" + fileLength);
|
||||||
|
} else {
|
||||||
|
// 전체 전송
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK); // 200
|
||||||
|
response.setHeader("Content-Length", String.valueOf(fileLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 파일 데이터 전송
|
||||||
|
randomFile = new RandomAccessFile(videoFile, "r");
|
||||||
|
randomFile.seek(rangeStart);
|
||||||
|
|
||||||
|
outputStream = response.getOutputStream();
|
||||||
|
byte[] buffer = new byte[8192]; // 8KB 버퍼
|
||||||
|
int bytesRead;
|
||||||
|
|
||||||
|
long bytesToWrite = rangeLength;
|
||||||
|
while (bytesToWrite > 0 && (bytesRead = randomFile.read(buffer, 0, (int) Math.min(bytesToWrite, buffer.length))) != -1) {
|
||||||
|
outputStream.write(buffer, 0, bytesRead);
|
||||||
|
bytesToWrite -= bytesRead;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputStream.flush();
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 클라이언트가 연결을 중단하는 경우 (예: 동영상 탐색) IOException이 발생할 수 있음
|
||||||
|
// 이 경우는 정상적인 동작이므로 에러 로그를 남기지 않음
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
sendError(response, "알 수 없는 오류가 발생했습니다.", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
||||||
|
} finally {
|
||||||
|
if (outputStream != null) {
|
||||||
|
try {
|
||||||
|
outputStream.close();
|
||||||
|
} catch (IOException e) {}
|
||||||
|
}
|
||||||
|
if (randomFile != null) {
|
||||||
|
try {
|
||||||
|
randomFile.close();
|
||||||
|
} catch (IOException e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클라이언트에게 에러 메시지를 전송하는 메소드.
|
||||||
|
* 한글 깨짐을 방지하기 위해 응답 인코딩을 UTF-8로 직접 설정합니다.
|
||||||
|
* @param response HttpServletResponse 객체
|
||||||
|
* @param message 전송할 에러 메시지
|
||||||
|
* @param status HTTP 상태 코드
|
||||||
|
*/
|
||||||
|
private void sendError(HttpServletResponse response, String message, int status) {
|
||||||
|
try {
|
||||||
|
// 1. 응답 상태 코드를 직접 설정합니다.
|
||||||
|
response.setStatus(status);
|
||||||
|
|
||||||
|
// 2. 응답의 컨텐츠 타입과 문자 인코딩을 UTF-8로 명확하게 지정합니다.
|
||||||
|
response.setContentType("text/plain; charset=UTF-8");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
|
||||||
|
// 3. 응답 본문에 직접 에러 메시지를 작성합니다.
|
||||||
|
response.getWriter().write(message);
|
||||||
|
response.getWriter().flush();
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
// 클라이언트가 연결을 끊었거나 다른 I/O 오류 발생 시
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<link href="/js/map/gis/css/map.css" rel="stylesheet"/>
|
<link href="/js/map/gis/css/map.css" rel="stylesheet"/>
|
||||||
|
|
||||||
|
|
||||||
<title>지도</title>
|
<title>지도</title>
|
||||||
</head>
|
</head>
|
||||||
<body onload="initMap();">
|
<body onload="initMap();">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
<filter-name>encodingFilter</filter-name>
|
<filter-name>encodingFilter</filter-name>
|
||||||
<url-pattern>*.do</url-pattern>
|
<url-pattern>*.do</url-pattern>
|
||||||
<url-pattern>*.json</url-pattern>
|
<url-pattern>*.json</url-pattern>
|
||||||
|
<url-pattern>/video/stream/*</url-pattern>
|
||||||
</filter-mapping>
|
</filter-mapping>
|
||||||
|
|
||||||
<!-- 멀티파트필터 적용 -->
|
<!-- 멀티파트필터 적용 -->
|
||||||
|
|
@ -74,6 +75,10 @@
|
||||||
<servlet-name>action</servlet-name>
|
<servlet-name>action</servlet-name>
|
||||||
<url-pattern>*.json</url-pattern>
|
<url-pattern>*.json</url-pattern>
|
||||||
</servlet-mapping>
|
</servlet-mapping>
|
||||||
|
<servlet-mapping>
|
||||||
|
<servlet-name>action</servlet-name>
|
||||||
|
<url-pattern>/video/stream/*</url-pattern>
|
||||||
|
</servlet-mapping>
|
||||||
<servlet>
|
<servlet>
|
||||||
<servlet-name>ImageServlet</servlet-name>
|
<servlet-name>ImageServlet</servlet-name>
|
||||||
<servlet-class>net.sf.jasperreports.j2ee.servlets.ImageServlet</servlet-class>
|
<servlet-class>net.sf.jasperreports.j2ee.servlets.ImageServlet</servlet-class>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue