웹 서버 개발 (18) DB 테이블에 파일 저장하기
File Upload 취약점 제거
- 업로드되는 파일을 DB 테이블에 저장하면 된다.
- File Upload 취약점을 없애기 위하여 업로드 파일 저장 위치를 db 테이블로 바꿔준다.
- 즉, 웹 서버에 파일을 바로 저장하지 않고 DB 테이블에 바이너리 형태로 저장해준다.
SQL 테이블에 데이터( 파일 ) 저장
게시글 테이블
게시글 테이블 추가 컬럼
- 업로드 파일 수 추가. 컬럼 디펄트 값 0
- 파일 sid 컬럼을 추가. 컬럼 디펄트 값 “[]”
컬럼 추가 SQL
alter table notice_board add file_cnt NUMBER(20) default 0 not null;
alter table notice_board add file_sids NVARCHAR2(200) default '[]' not null;
file_cnt 추가 SQL
update notice_board set file_cnt=? where sid=?;
file_sids 리스트 추가 SQL
- file_sids 에는 파일 sid 들의 리스트를 string 형태로 저장한다.
- 예를 들면
[1, 2, 3]
이런식으로 저장한다.
update notice_board set file_sids=? where sid=?;
file sid list 및 file cnt 동시에 추가하기.
update notice_board set file_cnt=?,file_sids=? where sid=?;
파일 sid 리스트 불러오기
select file_sids from notice_board where sid = ?;
파일 테이블
컬럼
- sid ( PRIMARY KEY )
- 사용자 sid ( 외래키 )
- 게시글 sid ( 외래키 )
- 파일 이름
- 파일 바이너리
FILE 테이블 만들기
- 게시글이나 사용자 계정을 삭제하기 전에 notice_file 테이블에서 게시글, 사용자 계정과 관련된 데이터 부터 삭제해야 한다.
create table notice_file (
sid NUMBER(20) PRIMARY KEY,
user_sid NUMBER(20),
notice_board_sid NUMBER(20),
file_name NVARCHAR2(200),
file_content blob,
foreign key (user_sid) references users (sid),
foreign key (notice_board_sid) references notice_board (sid)
);
notice_file 테이블 데이터 insert
insert into
notice_file (sid, user_sid, notice_board_sid, file_name, file_content)
values (?,?,?,?,?)
notice_file 테이블 데이터 delete
delete from notice_file where sid=?
delete from notice_file where notice_board_sid=?
delete from notice_file where user_sid=?
파일 이름 찾기
select file_name from notice_file where sid=?;
파일 찾기
select file_content from notice_file where sid=?;
파일 새로운 게시글로 이전하기
update notice_file set notice_board_sid=? where sid=? ;
파일 업로드 사용자 sid 가져오기
select user_sid from notice_file where sid=? ;
업로드 과정
-
게시글 성공적으로 업로드.
-
게시글 삭제 과정
-
사용자 계정 삭제 과정
Servlet Java 코드
MainPage.java
- 파일 업로드 및 새로운 글 저장할 때. 파일 저장하는 부분을 수정하여 웹 서버에 파일을 저장하지 않고 DB 테이블에 파일을 저장하였다.
- 파일을 바이너리 형태로 DB 테이블에 저장한다.
public class MainPage extends HttpServlet{
... 생략 ...
private boolean uriSearch(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException{
... 생략 ...
// 새로운 글 저장
if(request.getMethod().equals("POST") && uri.equals(noticeBoardSave)){
// ***** 파일 업로드 시작 *****
var noticeRe = "no file";
try{
// 올바른 파일인지 확인하고 db에 저장한다.
var parts = request.getParts();
var fileSidList = new ArrayList<Long>();
for(var part:parts){
if(!part.getHeader("Content-Disposition").contains("filename=")){
continue;
}
if(part.getSubmittedFileName().equals("")){
continue;
}
// 파일 이름에 금지된 문자가 사용되면 파일 업로드 안하기.
if(notCorrectFileName(part.getSubmittedFileName())){
continue;
}
if(fileSidList.size() > MAX_FILE_NUM){
noticeRe = "file max number";
break;
}
// 데이터 DB 테이블에 저장하기
var re = new NoticeFileDAO(getServletContext()).saveNoticeFile(user.getSid(), noticeSid, part);
part.delete(); // 임시파일 삭제
if(re != -1){
System.out.println("업로드 성공");
noticeRe = "file upload success";
fileSidList.add(re);
}else{
System.out.println("파일 업로드 실패");
noticeRe = "file upload fail";
}
}
// 게시글 file list 와 저장된 파일 갯수 저장하기.
notice.updateFileListAndCnt(fileSidList, noticeSid);
}catch(Exception e){
e.printStackTrace();
System.out.println("파일 업로드 실패");
noticeRe = "file upload fail";
}
// ***** 파일 업로드 end *****
... 생략 ...
- 게시글 테이블에 저장된 첨부파일 SID를 이용하여 파일 이름과 링크를 만들어 보여준다.
... 생략 ...
// 글 자세히 보기
if(request.getMethod().equals("GET") && uri.equals(noticeBoardDetail)){
... 생략 ...
// *** 파일들 읽어 오기 ***
// 파일 이름 찾기 및 href 링크 만들기
var fileNameList = new ArrayList<Pair<String,String>>();
var fileSidList = noticeDAO.getFileSidList(noticeSID);
var noticeFileDAO = new NoticeFileDAO(getServletContext());
for(var fileSid:fileSidList){
var fileName = noticeFileDAO.getFileName(fileSid);
fileNameList.add(new Pair<String,String>(fileName,fileSid.toString()));
}
request.setAttribute("fileNameList", fileNameList);
request.setAttribute("noticeBoardFileDownload", noticeBoardFileDownload);
// *** 파일들 읽어 오기 end ***
... 생략 ...
- 게시글을 수정할 때 게시글이 가지고 있는 업로드 파일을 삭제하거나 다운 받을 수 있는 링크를 보여준다.
... 생략 ...
// mod 2 글 수정 하기
if(request.getMethod().equals("GET") && uri.equals(noticeBoardModify)){
try{
... 생략 ...
// *** 파일들 읽어 오기 ***
// 파일 이름 찾기 및 href 링크 만들기
var fileNameList = new ArrayList<Pair<String,String>>();
var fileSidList = noticeDAO.getFileSidList(noticeSID);
var noticeFileDAO = new NoticeFileDAO(getServletContext());
for(var fileSid:fileSidList){
var fileName = noticeFileDAO.getFileName(fileSid);
fileNameList.add(new Pair<String,String>(fileName,fileSid.toString()));
}
request.setAttribute("fileNameList", fileNameList);
request.setAttribute("noticeBoardFileDownload", noticeBoardFileDownload);
request.setAttribute("noticeBoardFileDelete", noticeBoardFileDelete);
// *** 파일들 읽어 오기 end ***
... 생략 ...
- 게시글 수정을 완료하고 저장을 할 때 동작 순서의 변화가 있었다.
- 새롭게 업로드한 파일도 DB 파일 테이블에 저장해 준다.
... 생략 ...
// 글 수정 저장하기
if(request.getMethod().equals("POST") && uri.equals(noticeBoardModifySave)) {
... 생략 ...
try {
// 1. 전 저장위치
var noticeSID = notice.getSid();
// 2. 새로운 글 저장
var genTime = System.currentTimeMillis();
var newNoticeSid = noticeDAO.saveNotice(user, title, mainText, genTime);
if(newNoticeSid==0){ // 실패
result = "<script>alert('fail');location.href='"+noticeBoard+"'</script>";
simplePage(response, result);
return true;
}
// 3. 이전 게시글 파일 새로운 글로 이전
// notice_board_sid 새로운 글 sid로 바꾸기.
var fileSidList = noticeDAO.getFileSidList(noticeSID);
noticeDAO.updateFileListAndCnt(fileSidList, newNoticeSid);
var fileDAO = new NoticeFileDAO(getServletContext());
for(var fileSid:fileSidList){
fileDAO.changeNoticeSid(fileSid, newNoticeSid);
}
// 4. 이전 글 삭제
if(!noticeDAO.deleteNotice(sid)){ // 삭제 실패
return false;
}
// 5. ***** 파일 업로드 시작 *****
var noticeRe = "no new file";
try{
// 올바른 파일인지 확인하고 db에 저장한다.
var parts = request.getParts();
for(var part:parts){
if(!part.getHeader("Content-Disposition").contains("filename=")){
continue;
}
if(part.getSubmittedFileName().equals("")){
continue;
}
// 파일 이름에 금지된 문자가 사용되면 파일 업로드 안하기.
if(notCorrectFileName(part.getSubmittedFileName())){
continue;
}
if(fileSidList.size() > MAX_FILE_NUM){
noticeRe = "file max number";
break;
}
// 데이터 DB 테이블에 저장하기
var re = new NoticeFileDAO(getServletContext()).saveNoticeFile(user.getSid(), newNoticeSid, part);
part.delete(); // 임시파일 삭제
if(re != -1){
System.out.println("업로드 성공");
noticeRe = "file upload success";
fileSidList.add(re);
}else{
System.out.println("파일 업로드 실패");
noticeRe = "new file upload fail";
}
}
// 게시글 file list 와 저장된 파일 갯수 저장하기.
noticeDAO.updateFileListAndCnt(fileSidList, newNoticeSid);
}catch(Exception e){
e.printStackTrace();
System.out.println("파일 업로드 실패");
noticeRe = "new file upload fail";
}
// ***** 파일 업로드 end *****
... 생략 ...
- 게시글에서 파일 다운로드 링크를 눌러 파일을 다운로드 받을 때 웹 서버의 폴더에서 파일을 가져오는 것이 아닌 DB 파일 테이블에서 파일을 가지고 온다.
- 주의해야 할 점은 파일 다운로드가 다 될때 까지는 DB와의 연결이 끊겨서는 안된다.
... 생략 ...
// 게시글 파일 다운로드
if(uri.equals(noticeBoardFileDownload)){
long pageSid;
long fileSid;
try {
pageSid = Long.parseLong(request.getParameter("pageid"));
fileSid = Long.parseLong(request.getParameter("downlink"));
} catch (Exception e) {
e.printStackTrace();
return false;
}
var noticeDAO = new NoticeBoardDAO(getServletContext());
var notice = noticeDAO.getNotice(pageSid);
if(notice == null) {
return false;
}
var fileDAO = new NoticeFileDAO(getServletContext());
var is = fileDAO.getFile(fileSid);
if(is == null){
return false;
}
var fileSize = is.available();
var fileName = fileDAO.getFileName(fileSid);
// 사용자에게 다운로드 보내기
var sMimeType = getServletContext().getMimeType(fileName);
if(sMimeType == null || sMimeType.length() == 0){
sMimeType = "application/octet-stream";
}
BufferedOutputStream outs = null;
try {
response.setContentType(sMimeType+"; charset=utf-8");
if(fileSize > 0){
response.setContentLength(fileSize);
}
var userAgent = request.getHeader("User-Agent");
System.out.println(userAgent);
if(userAgent != null && userAgent.contains("MSIE 5.5")){ // MSIE 5.5 이하
return false;
}else if(userAgent != null && userAgent.contains("MSIE")){ // MS IE
return false;
}else{ // 모질라
response.setHeader("Content-Disposition", "attachment; filename="+ new String(fileName.getBytes("utf-8"), "latin1") + ";");
}
// 사용자에게 파일을 전송한다.
outs = new BufferedOutputStream(response.getOutputStream());
int read = -1;
byte[] b = new byte[8192];
while((read = is.read(b)) != -1){
outs.write(b, 0, read);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
is.close();
} catch (Exception e){
e.printStackTrace();
}
try {
outs.close();
} catch (Exception e){
e.printStackTrace();
}
// 다운로드 완료 된후 DB 연결을 해제해야한다.
fileDAO.Close();
}
System.out.println("사용자 파일 다운로드 "+ pageSid + " "+ fileSize);
return true;
}
- 게시글 수정 페이지에서 파일 삭제 버튼을 눌러 파일을 삭제할 때 사용자로 부터 받은 게시글 sid 와 파일 sid를 이용하여 파일 테이블에 저장된 파일 삭제해준다.
- 파일을 삭제하기 전 파일을 삭제하는 권환을 가지고 있는지 확인한다.
// 파일 삭제
if(request.getMethod().equals("GET") && uri.equals(noticeBoardFileDelete)){
var re = "fail";
try{
var noticeSid = Long.parseLong(request.getParameter("noticeID"));
var fileSid = Long.parseLong(request.getParameter("filename"));
var noticeDAO = new NoticeBoardDAO(getServletContext());
var notice = noticeDAO.getNotice(noticeSid);
// 게시글 존재하는지 확인하기
... 생략 ...
// 글 작성자와 현재 유저가 일지하는지 확인
... 생략 ...
// 업로드 파일 사용자 확인
var fileDAO = new NoticeFileDAO(getServletContext());
if(user.getSid() != fileDAO.getUploadUser(fileSid)){
System.out.println("글 작성자와 파일 업로드 사용자 명과 일치 안함");
simplePage(response, "{\"result\":\"fail\"}");
return true;
}
// 파일 삭제
if(!fileDAO.deleteFile(fileSid)){
simplePage(response, "{\"result\":\"fail\"}");
return true;
}
// 파일 삭제 end
re = "success";
var fileSidList = noticeDAO.getFileSidList(noticeSid);
fileSidList.remove(fileSid);
noticeDAO.updateFileListAndCnt(fileSidList, noticeSid);
System.out.println("삭제 완료!");
}catch(Exception e){
e.printStackTrace();
re = "fail";
}
NoticeBoardDAO.java
- 전에는 게시글을 삭제할 때 게시글과 함께 게시글 업로드 폴더를 삭제 하였지만 현재는 게시글과 관련이 있는 DB 파일 테이블의 데이터를 삭제하면 된다.
- 게시글을 삭제하기 전에 업로드 파일 부터 삭제하여야 한다. 그렇지 않으면 무결성 오류가 발생한다.
public class NoticeBoardDAO {
// 삭제
public boolean deleteNotice(long sid){
// 좋아요 테이블에서 게시글과 관련된 좋아요 기록 모두 삭제
new NoticeLikeDAO(context).deleteNoticeLike(sid);
// 업로드 파일 부터 삭제한다.
new NoticeFileDAO(context).deleteNoticeFile(sid);
... 생략 ...
- 게시글과 관련된 파일 sid 리스트를 String 타입으로 게시글에 저장해 주었다. 그리고 업로드 파일이 몇 개 인지도 저장해 주었다.
// file list 저장 및 저장된 파일 수 저장
public boolean updateFileListAndCnt(ArrayList<Long> fileSidList, long noticeSid){
var re = false;
var query = new StringBuilder();
query.append("update ");
query.append(tableName);
query.append(" set file_cnt=?,file_sids=? where sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1,fileSidList.size());
pstmt.setString(2, fileSidList.toString());
pstmt.setLong(3, noticeSid);
pstmt.executeUpdate();
re = true;
} catch (Exception e) {
e.printStackTrace();
re = false;
} finally {
Close();
}
return re;
}
- 게시글에 저장된 파일 sid 리스트 String을 ArrayList로 변환해준다.
// get file sid list
public ArrayList<Long> getFileSidList(long noticeSid){
ArrayList<Long> fileSidList = null;
var query = new StringBuilder();
query.append("select file_sids from ");
query.append(tableName);
query.append(" where sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, noticeSid);
var re = pstmt.executeQuery();
String listStr = "[]";
if(re.next()){
listStr = re.getString(1);
}
fileSidList = strToArrayList(listStr);
} catch (Exception e) {
e.printStackTrace();
fileSidList = new ArrayList<Long>();
} finally {
Close();
}
return fileSidList;
}
- String 타입을 ArrayList로 변환해준다.
private ArrayList<Long> strToArrayList(String listStr){
var arr = new ArrayList<Long>();
if(listStr.length() > 2){
arr.addAll(Arrays.stream(listStr.substring(1,listStr.length()-1).split(", ")).map(Long::parseLong).collect(Collectors.toList()));
}
return arr;
}
NoticeFileDAO.java
- 실제 DB 와 상호작용 할 수 있는 함수이다.
- DB 테이블의 파일을 삭제, 저장을 할 수 있다.
- 파일 이름이나 파일이 속한 게시글 sid, 사용자 sid 를 알 수 있다.
- 테이블에 저장된 바이너리 형태의 파일을 불러올 수 있다.
public class NoticeFileDAO {
private String tableName = "notice_file";
private Connection conn;
private DBConnection dbConn;
public NoticeFileDAO(ServletContext context){
dbConn = new DBConnection(context, "noticeBoard");
}
public long saveNoticeFile(long userSid, long noticeBoardSid, Part part){
long re = -1;
var sid = genSid();
var query = new StringBuilder();
query.append("insert into ");
query.append(tableName);
query.append(" (sid, user_sid, notice_board_sid, file_name, file_content) values(?,?,?,?,?)");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, sid);
pstmt.setLong(2, userSid);
pstmt.setLong(3, noticeBoardSid);
pstmt.setString(4, part.getSubmittedFileName());
pstmt.setBinaryStream(5, part.getInputStream(), part.getSize());
pstmt.executeUpdate();
re = sid;
} catch (Exception e) {
e.printStackTrace();
re = -1;
} finally {
Close();
}
return re;
}
// 게시글 에 속한 파일 삭제하기.
public void deleteNoticeFile(long noticeSid){
var query = new StringBuilder();
query.append("delete from ");
query.append(tableName);
query.append(" where notice_board_sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, noticeSid);
pstmt.executeUpdate();
} catch (Exception e) {
e.printStackTrace();
}finally{
Close();
}
}
public String getFileName(long sid){
String fileName = "none";
var query = new StringBuilder();
query.append("select file_name from ");
query.append(tableName);
query.append(" where sid=?");
try{
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, sid);
var re = pstmt.executeQuery();
if(re.next()){
fileName = re.getString(1);
}
}catch(Exception e){
e.printStackTrace();
fileName="none";
}finally {
Close();
}
return fileName;
}
// 파일 다운로드
public InputStream getFile(long fileSid){
InputStream re = null;
var query = new StringBuilder();
query.append("select file_content from ");
query.append(tableName);
query.append(" where sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, fileSid);
var result = pstmt.executeQuery();
if(result.next()){
var blob = result.getBlob(1);
re = blob.getBinaryStream();
}
} catch (Exception e) {
e.printStackTrace();
re = null;
}
// 다운로드가 완료 되기 전까지 DB연결이 해제되어야 한다.
// finally {
// Close();
// }
return re;
}
// 파일이 속하는 게시글 번호 바꾸기
public boolean changeNoticeSid(long fileSid, long newNoticeSid){
var re = false;
var query = new StringBuilder();
query.append("update ");
query.append(tableName);
query.append(" set notice_board_sid=? where sid=?");
try{
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, newNoticeSid);
pstmt.setLong(2, fileSid);
pstmt.executeUpdate();
re = true;
}catch(Exception e){
e.printStackTrace();
re = false;
}finally {
Close();
}
return re;
}
// 파일 sid 로 삭제
public boolean deleteFile(long fileSid){
var re = false;
var query = new StringBuilder();
query.append("delete from ");
query.append(tableName);
query.append(" where sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, fileSid);
pstmt.executeUpdate();
re = true;
} catch (Exception e) {
e.printStackTrace();
re = false;
}finally{
Close();
}
return re;
}
// 파일 업로드 사용자 sid
public long getUploadUser(long fileSid){
long re = -1;
var query = new StringBuilder();
query.append("select user_sid from ");
query.append(tableName);
query.append(" where sid=?");
try {
Connection();
var pstmt = conn.prepareStatement(query.toString());
pstmt.setLong(1, fileSid);
var result = pstmt.executeQuery();
if(result.next()){
re = result.getLong(1);
}
} catch (Exception e) {
e.printStackTrace();
re = -1;
} finally {
Close();
}
return re;
}
private long genSid(){
long sid = -2;
Connection();
try {
var query = new StringBuilder();
query.append("select coalesce(max(sid),-1) from ");
query.append(tableName);
var pstmt = conn.prepareStatement(query.toString());
var result = pstmt.executeQuery();
if(result.next()){
sid = result.getLong(1);
}
}catch(Exception e){
System.out.println(e.getMessage());
}finally{
Close();
}
return sid+1;
}
private void Connection(){
conn = dbConn.connectDB();
}
public void Close(){
try{
conn.close();
}catch(Exception e){
System.out.println(e.getMessage());
}
}
}
발생한 오류
ORA-00904: “NOTICE_FILE”: invalid identifier
- notice_file 테이블에 파일 저장 컬럼의 이름이 file_content ( BLOB ) 인데 notice_file 로 컬럼 이름을 잘못적어 발생한 오류이다. 컬럼 이름을 바르게 바꿔주면 해당 오류는 사라진다.
- notice_file 이라는 컬럼이 존재하지 않는다는 뜻이다.
java.lang.ClassCastException
- class javax.sql.rowset.serial.SerialBlob cannot be cast to class oracle.jdbc.internal.OracleBlob
- 이 에러는 어떻게 해결해야 하는지 모르겠다. 그냥 SerialBlob 을 사용하지않고 다른 방법을 이용하였다.
java.io.IOException: ORA-17008: Closed connection
- java.sql.SQLRecoverableException: ORA-17008: Closed connection
- DB 와 연결이 끊어져서 발생한 문제이다.
- 작업을 마칠 때 까지 DB 연결을 끊어서는 안된다.
참고 사이트
- 오라클 컬럼(Column) 추가, 삭제 방법 (컬럼 순서 변경) (tistory.com)
- Oracle’s JDBC BLOB support sucks…. George Aristy (llorllale.github.io)
- Java servlet to download file from database (codejava.net)
- Upload File to Database with Servlet and MySQL using BLOB (javaguides.net)
- Oracle 에서 Java를 사용하여 Blob형을 파일로 저장하기 (miyam.net)
댓글남기기