diff --git a/.gitignore b/.gitignore index 6fbb1a503..4892340ac 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,4 @@ gradle-app.setting *.hprof # End of https://www.toptal.com/developers/gitignore/api/gradle,intellij,java,macos + diff --git a/STUDY3.md b/STUDY3.md index 6e5156727..17b051122 100644 --- a/STUDY3.md +++ b/STUDY3.md @@ -38,4 +38,68 @@ > ### JDBC (Java Database Connectivity) > -> 데이터베이스에 연결하고 SQL 쿼리를 실행하는 표준 API \ No newline at end of file +> 데이터베이스에 연결하고 SQL 쿼리를 실행하는 표준 API + + + +----- +#### 0114 TODO (우선 글로만 게시글 올리는 기능 구현) +- [x] 글쓰기 버튼 추가 +- [x] 사용자에 따른 글쓰기 페이지 접근 제한 처리 +- [x] 폼에 입력된 데이터 받아오기 +- [x] ArticleController +- [x] 데이터 타입 만들고 저장하기 +- [x] controller mapping +- [x] 메인 화면에서 최신 게시글 보여주기 +- [ ] db에 정렬된 상태로 들어가도록 수정하기 + +> #### `CopyOnWriteArrayList` +> +> 다수의 스레드에서 동시에 글이 작성된다면? +> - `Collections.synchronizedList` vs `CopyOnWriteArrayList` +> - 인스타그램을 모티브로한 현재 서비스에선 읽기가 훨씬 많지 않나? + +> #### 글을 쓰는 대로 리스트에 추가되는 현재 상황에서 굳이 정렬이 필요한가? +> +> concurrency를 고려해보면 동시에 글을 올렸지만 네트워크 이슈 등의 문제로 작성 시간이 늦은 글이 더 먼저 추가될 수도 있을 듯 + + +> #### 예외 처리 시점? +> +> `IndexController` 에서 게시글 ID를 전달받았을 때, 존재하지 않는 ID에 대한 예외 처리를 어느 계층에서 수행할지 있었다. +> 상위 메서드인 `process`에서 미리 검증을 수행하는 방식은 +> 에러 상황을 조기에 발견하여 이후의 불필요한 비즈니스 로직 수행을 차단하는 관점에서 효율적이라고 생각했다. +> 실제 데이터를 읽어오는 하위 메서드인 `renderArticleSection`에서 직접 체크하는 방식의 경우, 검증 시점과 실제 데이터 사용 시점 사이의 차이가 작아 더 안전하다고 생각했다. +> 즉, `process`에서 ID의 유효성을 확인했더라도, 그 순간에 게시글이 삭제된다면 실제 렌더링 시점에는 예외가 발생할 수 있다. 결국 데이터 조회가 일어나는 시점과 검증 시점을 최대한 가깝게 해 데이터의 정합성을 높이는 것이 더 견고한 설계라고 판단했다. +> 따라서 하위 메서드에서 예외를 던지고 상위에서 이를 받아 처리하는 구조를 선택하기로 했다 + + + +----- +#### 0115 TODO +- [x] db 연동하기 +- [ ] 이미지 업로드 모듈 (저장, 조회) +- [ ] 이미지 모듈 적용 - 글쓰기 페이지 +- [ ] 이미지 모듈 적용 - 마이페이지 +- [ ] 게시글 작성 기능 수정 (비즈니스 로직) +- [ ] 해당 내용 테스트 +- [ ] javadoc +- [ ] + 로그인 실패 시 처리 수정 + +> #### +> +> DB 연동을 마치고 회원가입과 로그인 로직을 테스트하던 중 에러가 발생했다. +> 에러 로그를 보니 Database.findUserById와 User.(생성자)이 서로를 무한하게 호출하는 문제가 있었다. +> 객체의 생성자 안에 Database.findUserById()를 호출하는 검증 로직을 넣었다. +> 하지만 Database.findUserById()는 DB에서 데이터를 찾으면 그 결과값으로 다시 new User()를 호출해 객체를 생성하려고 한다. +> -> 무한루프 발생 +> 검증 로직을 생성자에서 빼서 Controller로 옮겨 해결하였다. +> 관심사 분리가 왜 중요한지...설계의 필요성.. 알게되었다. + + +> #### multipart reqeust +> +> `header` ) `content-type` ) `boundary`= +> : 브라우저가 랜덤생성한 구분자 +> +> `Body` ) 시작바운더리 + 파트헤더 + \r\n + data + \r\n + 구분바운더리 + 헤더 + \r\n + imagedata + 종료바운더리 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 25dd8fcb7..736b3da2a 100644 --- a/build.gradle +++ b/build.gradle @@ -14,9 +14,11 @@ dependencies { testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' implementation 'ch.qos.logback:logback-classic:1.2.3' + runtimeOnly 'com.h2database:h2:2.2.224' testImplementation 'org.assertj:assertj-core:3.16.1' + } test { diff --git a/img_uploads/0304571d-cc61-444a-b3b6-dfc6f5479a67_testimg1.png b/img_uploads/0304571d-cc61-444a-b3b6-dfc6f5479a67_testimg1.png new file mode 100644 index 000000000..0bf9293bf Binary files /dev/null and b/img_uploads/0304571d-cc61-444a-b3b6-dfc6f5479a67_testimg1.png differ diff --git a/img_uploads/0dfcde88-4275-4de3-9899-22fa61afde3e_image1.png b/img_uploads/0dfcde88-4275-4de3-9899-22fa61afde3e_image1.png new file mode 100644 index 000000000..b03d17820 Binary files /dev/null and b/img_uploads/0dfcde88-4275-4de3-9899-22fa61afde3e_image1.png differ diff --git a/img_uploads/12b02ce2-86a4-4c00-bcf1-c9cfea006b7c_image1.jpg b/img_uploads/12b02ce2-86a4-4c00-bcf1-c9cfea006b7c_image1.jpg new file mode 100644 index 000000000..459ab2b34 Binary files /dev/null and b/img_uploads/12b02ce2-86a4-4c00-bcf1-c9cfea006b7c_image1.jpg differ diff --git a/img_uploads/2c9a8faa-88d4-4e79-a4c1-220a9e933439_ b/img_uploads/2c9a8faa-88d4-4e79-a4c1-220a9e933439_ new file mode 100644 index 000000000..e69de29bb diff --git a/img_uploads/96b568c8-109d-4d9c-b252-2f479dd8f38a_image1.jpg b/img_uploads/96b568c8-109d-4d9c-b252-2f479dd8f38a_image1.jpg new file mode 100644 index 000000000..459ab2b34 Binary files /dev/null and b/img_uploads/96b568c8-109d-4d9c-b252-2f479dd8f38a_image1.jpg differ diff --git a/img_uploads/b4a930f6-9133-424a-ac69-d8b99cf09a63_image1.png b/img_uploads/b4a930f6-9133-424a-ac69-d8b99cf09a63_image1.png new file mode 100644 index 000000000..b03d17820 Binary files /dev/null and b/img_uploads/b4a930f6-9133-424a-ac69-d8b99cf09a63_image1.png differ diff --git a/img_uploads/c36aba45-ff76-4677-8278-3e8fac81507b_testimg1.png b/img_uploads/c36aba45-ff76-4677-8278-3e8fac81507b_testimg1.png new file mode 100644 index 000000000..0bf9293bf Binary files /dev/null and b/img_uploads/c36aba45-ff76-4677-8278-3e8fac81507b_testimg1.png differ diff --git a/img_uploads/d3968b2a-0fee-4982-b795-df6cf60d0fc3_image1.jpg b/img_uploads/d3968b2a-0fee-4982-b795-df6cf60d0fc3_image1.jpg new file mode 100644 index 000000000..459ab2b34 Binary files /dev/null and b/img_uploads/d3968b2a-0fee-4982-b795-df6cf60d0fc3_image1.jpg differ diff --git a/img_uploads/f626f2d2-284b-4bef-ac87-00cb75816cc4_image1.jpg b/img_uploads/f626f2d2-284b-4bef-ac87-00cb75816cc4_image1.jpg new file mode 100644 index 000000000..459ab2b34 Binary files /dev/null and b/img_uploads/f626f2d2-284b-4bef-ac87-00cb75816cc4_image1.jpg differ diff --git a/img_uploads/fae36fc0-4e0d-4e6c-b2ae-190b37d7722d_testimg.jpg b/img_uploads/fae36fc0-4e0d-4e6c-b2ae-190b37d7722d_testimg.jpg new file mode 100644 index 000000000..6836a475f Binary files /dev/null and b/img_uploads/fae36fc0-4e0d-4e6c-b2ae-190b37d7722d_testimg.jpg differ diff --git a/img_uploads/image1.png b/img_uploads/image1.png new file mode 100644 index 000000000..b03d17820 Binary files /dev/null and b/img_uploads/image1.png differ diff --git "a/resource/static/img_uploads/\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" "b/resource/static/img_uploads/\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" new file mode 100644 index 000000000..584f34c21 Binary files /dev/null and "b/resource/static/img_uploads/\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" differ diff --git "a/resource/static/img_uploads\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" "b/resource/static/img_uploads\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" new file mode 100644 index 000000000..584f34c21 Binary files /dev/null and "b/resource/static/img_uploads\354\212\244\355\201\254\353\246\260\354\203\267 2026-01-09 \354\230\244\354\240\204 10.53.53.png" differ diff --git a/src/main/java/db/DBConnection.java b/src/main/java/db/DBConnection.java new file mode 100644 index 000000000..50abb5250 --- /dev/null +++ b/src/main/java/db/DBConnection.java @@ -0,0 +1,22 @@ +package db; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +public class DBConnection { + private static final String URL = "jdbc:h2:tcp://localhost/~/test"; + private static final String USER = "sa"; + private static final String PASSWORD = ""; + + public static Connection getConnection() { + try { + Class.forName("org.h2.Driver"); + return DriverManager.getConnection(URL, USER, PASSWORD); + } catch (ClassNotFoundException | SQLException e) { + e.printStackTrace(); + throw new RuntimeException("DB 연결 실패"); + } + } + +} diff --git a/src/main/java/db/Database.java b/src/main/java/db/Database.java index 3f4d75fe8..b2b75f5fc 100644 --- a/src/main/java/db/Database.java +++ b/src/main/java/db/Database.java @@ -1,39 +1,184 @@ package db; +import model.Article; import model.User; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import webserver.controller.CreateUserController; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.*; +import java.sql.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; public class Database { - private static Map users = new HashMap<>(); - private static Map sessions = new HashMap<>(); + private static final Logger logger = LoggerFactory.getLogger(Database.class); + private static Map sessions = new ConcurrentHashMap<>(); + //private static Map articles = new ConcurrentHashMap<>(); + private static List
sortedArticles = new CopyOnWriteArrayList<>(); + + // --- User 관련 --- public static void addUser(User user) { - users.put(user.getUserId(), user); + String sql = "INSERT INTO users (user_id,password,name) VALUES (?, ?, ?)"; + + try (Connection connec = DBConnection.getConnection(); + PreparedStatement pstmt = connec.prepareStatement(sql)) { + pstmt.setString(1, user.getUserId()); + pstmt.setString(2, user.getPassword()); + pstmt.setString(3, user.getName()); + + pstmt.executeUpdate(); + logger.debug("[DB 저장 성공] user id :" + user.getUserId()); + } catch (SQLException e) { + e.printStackTrace(); + } + } public static User findUserById(String userId) { - return users.get(userId); + String sql = "SELECT * FROM users WHERE user_id = ?"; + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + + pstmt.setString(1, userId); + + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { // 결과가 있다면 + return new User( + rs.getString("user_id"), + rs.getString("password"), + rs.getString("name") + ); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return null; } public static User findUserByName(String name) { - return users.values().stream() - .filter(user -> user.getName().equals(name)) - .findFirst() - .orElse(null); + String sql = "SELECT * FROM users WHERE name = ?"; + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, name); + + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + return new User( + rs.getString("user_id"), + rs.getString("password"), + rs.getString("name") + ); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return null; } public static Collection findAll() { - return users.values(); + List userList = new ArrayList<>(); + String sql = "SELECT * FROM users"; + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql); + ResultSet rs = pstmt.executeQuery()) { + + while (rs.next()) { + User user = new User( + rs.getString("user_id"), + rs.getString("password"), + rs.getString("name") + ); + userList.add(user); + } + } catch (SQLException e) { + e.printStackTrace(); + } + return userList; } + // --- Session 관련 --- public static void addSession(String sessionId, User user) { sessions.put(sessionId, user); } public static User getUserBySessionId(String sessionId) { + if (sessionId == null) return null; return sessions.get(sessionId); } -} + + // --- Article 관련 --- + public static void addArticle(Article article) { + String sql = "INSERT INTO ARTICLE (ID, AUTHOR_ID, CONTENT, IMAGE_PATH) VALUES (?, ?, ?, ?)"; + + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + + pstmt.setString(1, article.getId()); + pstmt.setString(2, article.getAuthorId()); + pstmt.setString(3, article.getContent()); + pstmt.setString(4, article.getImagePath()); + + pstmt.executeUpdate(); + System.out.println(" [DB 저장 성공] 작성자: " + article.getAuthorId()); + + } catch (SQLException e) { + System.err.println(" [DB 저장 실패] error: " + e.getMessage()); + e.printStackTrace(); + } + } + + public static Article findArticleById(String id) { + String sql = "SELECT * FROM ARTICLE WHERE ID = ?"; + + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + + pstmt.setString(1, id); + + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + return new Article( + rs.getString("ID"), + rs.getString("AUTHOR_ID"), + rs.getString("CONTENT"), + rs.getString("IMAGE_PATH"), + rs.getTimestamp("CREATED_AT").toLocalDateTime() + ); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } + + public static List
findAllArticles() { + List
articles = new ArrayList<>(); + String sql = "SELECT * FROM ARTICLE ORDER BY CREATED_AT DESC"; + + try (Connection conn = DBConnection.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql); + ResultSet rs = pstmt.executeQuery()) { + + while (rs.next()) { + articles.add(new Article( + rs.getString("ID"), + rs.getString("AUTHOR_ID"), + rs.getString("CONTENT"), + rs.getString("IMAGE_PATH"), + rs.getTimestamp("CREATED_AT").toLocalDateTime() + )); + } + } catch (SQLException e) { + e.printStackTrace(); + } + return articles; + } +} \ No newline at end of file diff --git a/src/main/java/http/HttpRequest.java b/src/main/java/http/HttpRequest.java index d6f59db7a..f57d51e2b 100644 --- a/src/main/java/http/HttpRequest.java +++ b/src/main/java/http/HttpRequest.java @@ -3,18 +3,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import util.IOUtils; -import webserver.RequestHandler; -import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import static http.HttpMethod.from; +import static util.IOUtils.indexOf; public class HttpRequest { @@ -23,6 +21,7 @@ public class HttpRequest { private HttpMethod method; private String path; private String queryString; + private String boundary; private Map headers = new HashMap<>(); private Map params = new HashMap<>(); private Map cookies = new HashMap<>(); @@ -30,18 +29,17 @@ public class HttpRequest { public HttpRequest(InputStream in) { try { - BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); - String line = br.readLine(); + String line = readLine(in); if (line == null) return; parseRequestLine(line); logger.debug("Method:{}, path :{} ", method, path); - parseHeaders(br); + parseHeaders(in); if (headers.containsKey("content-length") && headers.get("content-length") != null) { logger.debug("Content-Length: {}, Content-Type: {}", headers.get("content-length"), headers.get("content-type")); - parseBody(br); + parseBody(in); } @@ -50,6 +48,18 @@ public HttpRequest(InputStream in) { } } + private String readLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int b; + while ((b = in.read()) != -1) { + if (b == '\r') continue; + if (b == '\n') break; + sb.append((char) b); + } + if (b == -1 && sb.length() == 0) return null; + return sb.toString(); + } + // 여기 하드코딩 바꾸기 private void parseRequestLine(String requestLine) { if (requestLine == null || requestLine.isEmpty()) { @@ -92,9 +102,9 @@ private void parseQueryString(String queryString) { } } - private void parseHeaders(BufferedReader br) throws IOException { + private void parseHeaders(InputStream in) throws IOException { String line; - while ((line = br.readLine()) != null && !line.isEmpty()) { + while (!(line = readLine(in)).isEmpty()) { String[] headerTokens = line.split(":"); if (headerTokens.length >= 2) { String key = headerTokens[0].trim().toLowerCase(); @@ -105,9 +115,21 @@ private void parseHeaders(BufferedReader br) throws IOException { if ("Cookie".equalsIgnoreCase(key)) { parseCookies(value); } + // multipart : boundary파싱 + if ("content-type".equals(key) && value.contains("multipart/form-data")) { + // value 예시: "multipart/form-data; boundary=----WebKitFormBoundary..." + String[] parts = value.split("boundary="); + if (parts.length > 1) { + this.boundary = parts[1]; // HttpRequest 클래스에 필드로 저장해두면 나중에 쓰기 편해요! + logger.debug("Boundary found: {}", this.boundary); + } + logger.debug("[boundary] : " + this.boundary); + } + } - HttpRequest.logger.debug("Header: {}", line); + logger.debug("Header: {}", line); } + } private void parseCookies(String cookieHeader) { @@ -127,33 +149,101 @@ private void parseCookies(String cookieHeader) { logger.debug("Cookies: {}", cookies); } - private void parseBody(BufferedReader br) throws IOException { + private void parseBody(InputStream in) throws IOException { int contentLength = Integer.parseInt(headers.get("content-length")); + byte[] bodyBytes = in.readNBytes(contentLength); - // 텍스트 데이터 기준 ( byte 방식으로 변환 필요) - char[] bodyChars = new char[contentLength]; - int readCount = 0; - while (readCount < contentLength) { - int result = br.read(bodyChars, readCount, contentLength - readCount); - if (result == -1) break; - readCount += result; + ContentType contentType = ContentType.from(headers.get("content-type")); + + if (boundary != null || (contentType != null && contentType == ContentType.MULTIPART)) { + HttpRequest.logger.debug("멀티파트 데이터 파싱 시작 (바운더리: {})", boundary); + parseMultipartBody(bodyBytes); + } else if (contentType != null && contentType == ContentType.FORM_URLENCODED) { + String body = new String(bodyBytes, StandardCharsets.UTF_8); + parseQueryString(body); + HttpRequest.logger.debug("일반 폼 데이터 파싱 완료: {}", params); + } else { + HttpRequest.logger.warn("지원하지 않는 컨텐츠타입"); + throw new IllegalArgumentException("Unsupported Content-Type: " + contentType); } - String body = new String(bodyChars, 0, readCount); - ContentType contentType = ContentType.from(headers.get("content-type")); + } - switch (contentType) { - case FORM_URLENCODED -> { - parseQueryString(body); - HttpRequest.logger.debug("Body Params (Form) : {}", params); + private void parseMultipartBody(byte[] bodyBytes) { + try { + byte[] boundaryBytes = ("--" + this.boundary).getBytes(StandardCharsets.UTF_8); + byte[] doubleCrlf = "\r\n\r\n".getBytes(StandardCharsets.UTF_8); + + int startPos = indexOf(bodyBytes, boundaryBytes, 0); + + while (startPos != -1) { + int nextBoundaryPos = indexOf(bodyBytes, boundaryBytes, startPos + boundaryBytes.length); + if (nextBoundaryPos == -1) break; + + // 2. 현재 파트의 전체 데이터 추출 (바운더리 사이의 구간) + // 파트 시작 위치: startPos + boundaryBytes.length + 2 (\r\n) + // 파트 끝 위치: nextBoundaryPos - 2 (\r\n) + int partStart = startPos + boundaryBytes.length + 2; + int partEnd = nextBoundaryPos - 2; + + // 3. 파트 내에서 헤더와 데이터의 경계(\r\n\r\n) 찾기 + int headerEnd = indexOf(bodyBytes, doubleCrlf, partStart); + if (headerEnd != -1 && headerEnd < partEnd) { + // 파트 헤더 추출 + String partHeader = new String(bodyBytes, partStart, headerEnd - partStart, StandardCharsets.UTF_8); + + // 실제 데이터 시작 및 끝 계산 + int dataStart = headerEnd + doubleCrlf.length; + int dataEnd = partEnd; + byte[] data = java.util.Arrays.copyOfRange(bodyBytes, dataStart, dataEnd); + + // 4. 헤더 분석 후 처리 + processPart(partHeader, data); + } + startPos = nextBoundaryPos; } - default -> { - HttpRequest.logger.warn("지원하지 않는 컨텐츠타입 : {}", contentType); - throw new IllegalArgumentException("Unsupported Content-Type: " + contentType); + } catch (Exception e) { + logger.error("멀티파트 파싱 중 에러 발생: {}", e.getMessage()); + } + } + + private void processPart(String header, byte[] data) { + if (header.contains("filename=")) { + // [파일 파트] - 이미지 + String fileName = extractFileName(header); + + if (fileName == null || fileName.isEmpty() || data.length == 0) { + logger.debug("파일이 전송되지 않았습니다. (Empty file part)"); + return; } + logger.debug("파일 파트 발견: {}, 크기: {} bytes", fileName, data.length); + + // 임시 저장 테스트 (나중에 분리) + String uniquenessName = IOUtils.saveFile(fileName, data); + params.put("imagePath", uniquenessName); + } else { + // [텍스트 파트] - content 등 + String name = extractName(header); + String value = new String(data, StandardCharsets.UTF_8); + params.put(name, value); + logger.debug("텍스트 파트 발견: {} = {}", name, value); } } + private String extractName(String header) { + // Content-Disposition: form-data; name="content" 에서 content 추출 + int start = header.indexOf("name=\"") + 6; + int end = header.indexOf("\"", start); + return header.substring(start, end); + } + + private String extractFileName(String header) { + // Content-Disposition: form-data; name="image"; filename="dog.png" 에서 dog.png 추출 + int start = header.indexOf("filename=\"") + 10; + int end = header.indexOf("\"", start); + return header.substring(start, end); + } + private void parseHeaders(BufferedReader br) throws IOException { String line; while ((line = br.readLine()) != null && !line.isEmpty()) { @@ -221,4 +311,8 @@ public Map getCookies() { public String getCookie(String key) { return this.cookies.get(key); } + + public String getImagePath() { + return this.getParams("imagePath"); + } } diff --git a/src/main/java/model/Article.java b/src/main/java/model/Article.java new file mode 100644 index 000000000..5ea7896c0 --- /dev/null +++ b/src/main/java/model/Article.java @@ -0,0 +1,50 @@ +package model; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class Article { + private String imagePath; + private String id; + private String content; + private String authorId; + private LocalDateTime createdAt; + + //생성용 + public Article(String authorId, String content, String imagePath) { + this.id = UUID.randomUUID().toString(); + this.authorId = authorId; + this.content = content; + this.imagePath = imagePath; + this.createdAt = LocalDateTime.now(); + } + + //조회용 + public Article(String id, String authorId, String content, String imagePath, LocalDateTime createdAt) { + this.id = id; + this.authorId = authorId; + this.content = content; + this.imagePath = imagePath; + this.createdAt = createdAt; + } + + public String getId() { + return id; + } + + public String getContent() { + return content; + } + + public String getAuthorId() { + return authorId; + } + + public String getImagePath() { + return imagePath; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/model/User.java b/src/main/java/model/User.java index 19b44da1f..138a779b2 100644 --- a/src/main/java/model/User.java +++ b/src/main/java/model/User.java @@ -6,25 +6,15 @@ public class User { private String userId; private String password; private String name; - private String email; - public User(String userId, String password, String name, String email) { + public User(String userId, String password, String name) { validate(userId, "아이디를 4자 이상 입력해주세요."); validate(password, "비밀번호를 4자 이상 입력해주세요."); validate(name, "이름을 4자 이상 입력해주세요."); - if (Database.findUserById(userId) != null) { - throw new IllegalArgumentException("이미 존재하는 아이디입니다."); - } - - if (Database.findUserByName(name) != null) { - throw new IllegalArgumentException("이미 존재하는 닉네임입니다."); - } - this.userId = userId.trim(); this.password = password.trim(); this.name = name; - this.email = email; } public String getUserId() { @@ -39,13 +29,9 @@ public String getName() { return name; } - public String getEmail() { - return email; - } - @Override public String toString() { - return "User [userId=" + userId + ", password=" + password + ", name=" + name + ", email=" + email + "]"; + return "User [userId=" + userId + ", password=" + password + ", name=" + name + "]"; } private void validate(String value, String errorMessage) { diff --git a/src/main/java/util/IOUtils.java b/src/main/java/util/IOUtils.java index 555ae31ec..9ab98866b 100644 --- a/src/main/java/util/IOUtils.java +++ b/src/main/java/util/IOUtils.java @@ -1,11 +1,18 @@ package util; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.UUID; + public class IOUtils { + private static final Logger logger = LoggerFactory.getLogger(IOUtils.class); + private IOUtils() { } @@ -46,4 +53,37 @@ public static byte[] readResourceAsBytes(String resourcePath) { } } + // 바이트 배열에서 특정 패턴의 시작 위치를 찾는 메서드 + public static int indexOf(byte[] data, byte[] pattern, int start) { + for (int i = start; i <= data.length - pattern.length; i++) { + boolean match = true; + for (int j = 0; j < pattern.length; j++) { + if (data[i + j] != pattern[j]) { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } + + public static String saveFile(String fileName, byte[] data) { + try { + String uniqueFileName = UUID.randomUUID().toString() + "_" + fileName; + + String uploadDir = "img_uploads/"; + java.nio.file.Path path = java.nio.file.Paths.get(uploadDir + uniqueFileName); + + java.nio.file.Files.createDirectories(path.getParent()); + java.nio.file.Files.write(path, data); + + logger.debug("파일 저장 완료: {}", uniqueFileName); + return uniqueFileName; + } catch (IOException e) { + logger.error("파일 저장 실패: {}", e.getMessage()); + return ""; + } + + } } diff --git a/src/main/java/webserver/AuthChecker.java b/src/main/java/webserver/AuthChecker.java index 04679eab1..3dba5fe56 100644 --- a/src/main/java/webserver/AuthChecker.java +++ b/src/main/java/webserver/AuthChecker.java @@ -11,16 +11,22 @@ public class AuthChecker { private static final Logger logger = LoggerFactory.getLogger(AuthChecker.class); - private static final Set protectedPaths = Set.of("/mypage"); + private static final Set protectedPaths = Set.of("/mypage", "/article"); private static final Set guestOnlyPaths = Set.of("/login", "/registration"); // 인증이 필요한 경로인지 확인 public static boolean isProtectedPath(String path) { - return protectedPaths.contains(path); + String normalizedPath = path.endsWith("/") && path.length() > 1 + ? path.substring(0, path.length() - 1) + : path; + return protectedPaths.contains(normalizedPath); } public static boolean isGuestOnlyPath(String path) { - return guestOnlyPaths.contains(path); + String normalizedPath = path.endsWith("/") && path.length() > 1 + ? path.substring(0, path.length() - 1) + : path; + return guestOnlyPaths.contains(normalizedPath); } // 로그인 상태 확인 @@ -32,6 +38,7 @@ public static boolean isLoggedIn(HttpRequest request) { public static boolean checkAuthentication(HttpRequest request, HttpResponse response) { String path = request.getPath(); + if (isProtectedPath(path)) { if (!isLoggedIn(request)) { logger.debug("로그인하지 않은 사용자의 허용되지 않은 경로 접근 :{}", path); diff --git a/src/main/java/webserver/RequestHandler.java b/src/main/java/webserver/RequestHandler.java index 8b00f42f6..72627a288 100644 --- a/src/main/java/webserver/RequestHandler.java +++ b/src/main/java/webserver/RequestHandler.java @@ -2,9 +2,7 @@ import java.io.*; import java.net.Socket; -import java.util.Set; -import db.Database; import http.HttpMethod; import http.HttpRequest; import http.HttpResponse; diff --git a/src/main/java/webserver/StaticResourceProcessor.java b/src/main/java/webserver/StaticResourceProcessor.java index 1cfa8ed3d..8090988d7 100644 --- a/src/main/java/webserver/StaticResourceProcessor.java +++ b/src/main/java/webserver/StaticResourceProcessor.java @@ -8,13 +8,27 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.text.Normalizer; public class StaticResourceProcessor { private static final Logger logger = LoggerFactory.getLogger(StaticResourceProcessor.class); + private static final String UPLOAD_PATH_PREFIX = "/img_uploads/"; + private static final String UPLOAD_DIRECTORY = "img_uploads/"; public boolean isExistPath(String path) { + + if (path.startsWith(UPLOAD_PATH_PREFIX)) { + String fileName = decodePath(path.substring(UPLOAD_PATH_PREFIX.length())); + File file = new File(UPLOAD_DIRECTORY + fileName); + return file.exists(); + } + String resourcePath = "/static" + (path.endsWith("/") ? path + "index.html" : path); try (InputStream is = getClass().getResourceAsStream(resourcePath)) { return is != null; @@ -27,6 +41,10 @@ public void process(HttpRequest request, HttpResponse response) { String path = request.getPath(); String queryString = request.getQueryString(); + if (path.startsWith(UPLOAD_PATH_PREFIX)) { + serveFromDisk(path, response); + return; + } if (path.lastIndexOf(".") == -1 && !path.endsWith("/")) { String redirectPath = path + "/"; if (queryString != null) { @@ -53,6 +71,32 @@ public void process(HttpRequest request, HttpResponse response) { } } + private void serveFromDisk(String path, HttpResponse response) { + try { + String fileName = decodePath(path.substring(UPLOAD_PATH_PREFIX.length())); + File file = new File(UPLOAD_DIRECTORY + fileName); + + logger.debug("실제 파일 찾기 시도 (절대경로): {}", file.getAbsolutePath()); + if (!file.exists()) { + response.sendError(HttpStatus.NOT_FOUND, "Image not found on disk"); + return; + } + + byte[] body = Files.readAllBytes(file.toPath()); + String extension = extractExtension(path); + response.forwardWithContentType(body, MimeType.getContentType(extension)); + logger.debug("Disk Resource Served: {}", file.getAbsolutePath()); + } catch (IOException e) { + logger.error("Disk file read error: {}", e.getMessage()); + response.sendError(HttpStatus.INTERNAL_SERVER_ERROR, "File read error"); + } + } + + private String decodePath(String path) { + String decoded = URLDecoder.decode(path, StandardCharsets.UTF_8); + return Normalizer.normalize(decoded, Normalizer.Form.NFC); + } + private String extractExtension(String path) { int dotIndex = path.lastIndexOf("."); return (dotIndex != -1) ? path.substring(dotIndex + 1) : "html"; diff --git a/src/main/java/webserver/controller/ArticleController.java b/src/main/java/webserver/controller/ArticleController.java new file mode 100644 index 000000000..89267258e --- /dev/null +++ b/src/main/java/webserver/controller/ArticleController.java @@ -0,0 +1,60 @@ +package webserver.controller; + +import db.Database; +import http.HttpRequest; +import http.HttpResponse; +import model.Article; +import model.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import webserver.AuthChecker; + +public class ArticleController implements Controller { + private static final Logger logger = LoggerFactory.getLogger(ArticleController.class); + private static final int MAX_CONTENT_LENGTH = 10000; + + public void process(HttpRequest request, HttpResponse response) { + + if (!AuthChecker.isLoggedIn(request)) { + logger.debug("인증되지 않은 사용자의 접근"); + response.sendRedirect("/login"); + return; + } + + String content = request.getParams("content"); + User user = Database.getUserBySessionId(request.getCookie("sid")); + + String imagePath = request.getImagePath(); + + if (imagePath == null || imagePath.isEmpty()) { + logger.warn("이미지 업로드 누락: 이미지가 없는 게시글은 작성이 불가합니다."); + response.sendRedirect("/article?error=missing_image"); + return; + } + + if (content == null) { + content = ""; + } + + if (content.length() > MAX_CONTENT_LENGTH) { + logger.warn("content 길이 제한 초과: {} characters (max: {})", content.length(), MAX_CONTENT_LENGTH); + response.sendRedirect("/article?error=length_exceeded"); + return; + } + if (user == null) { //세션 만료 대비 + logger.debug("Session expired or invalid for article creation"); + response.sendRedirect("/login"); + return; + } + + String userId = user.getUserId(); + + Article article = new Article(userId, content, imagePath); + Database.addArticle(article); + logger.debug("New article created by user: {}, content length: {}", userId, content.length()); + + + response.sendRedirect("/index.html?id=" + article.getId()); + + } +} diff --git a/src/main/java/webserver/controller/CreateUserController.java b/src/main/java/webserver/controller/CreateUserController.java index 3c6eaf7cc..da4a5767b 100644 --- a/src/main/java/webserver/controller/CreateUserController.java +++ b/src/main/java/webserver/controller/CreateUserController.java @@ -14,14 +14,26 @@ public class CreateUserController implements Controller { public void process(HttpRequest request, HttpResponse response) { - try { - Map params = request.getParams(); - User user = new User( - params.get("userId"), - params.get("password"), - params.get("name"), - params.get("email") - ); //이게 효율적인? 적합한 방법인지 모르겟다.. + Map params = request.getParams(); + String userId = request.getParams("userId"); + String name = request.getParams("name"); + + if (Database.findUserById(userId) != null) { + logger.error("이미 존재하는 아이디입니다: {}", userId); + return; + } + + // 2. 중복 닉네임 확인 + if (Database.findUserByName(name) != null) { + logger.error("이미 존재하는 닉네임입니다: {}", name); + return; + } + + User user = new User( + params.get("userId"), + params.get("password"), + params.get("name") + ); //이게 효율적인? 적합한 방법인지 모르겟다.. logger.debug("New User created : {}", user); Database.addUser(user); diff --git a/src/main/java/webserver/controller/IndexController.java b/src/main/java/webserver/controller/IndexController.java index 3bfec361f..4ab69ab3a 100644 --- a/src/main/java/webserver/controller/IndexController.java +++ b/src/main/java/webserver/controller/IndexController.java @@ -4,11 +4,15 @@ import http.ContentType; import http.HttpRequest; import http.HttpResponse; +import model.Article; import model.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import util.IOUtils; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; import java.util.Map; public class IndexController implements Controller { @@ -16,17 +20,24 @@ public class IndexController implements Controller { public void process(HttpRequest request, HttpResponse response) { - String html = readFile("/index.html"); + try { + String html = readFile("/index.html"); - String sid = request.getCookie("sid"); - User user = Database.getUserBySessionId(sid); + String sid = request.getCookie("sid"); + User user = Database.getUserBySessionId(sid); - String authSection = renderAuthSection(user); - String dynamicHtml = html.replace("{{LOGIN_SECTION}}", authSection); + String targetId = request.getParams("id"); + html = renderArticleSection(html, targetId); - response.sendBody(dynamicHtml, ContentType.HTML); // 바뀐 내용 전달 - } + String authSection = renderAuthSection(user); + html = html.replace("{{LOGIN_SECTION}}", authSection); + response.sendBody(html, ContentType.HTML); // 바뀐 내용 전달 + } catch (IllegalArgumentException e) { + logger.warn("article not found: {}", e.getMessage()); + response.sendRedirect("/"); + } + } private String readFile(String filePath) { String resourcePath = filePath; @@ -56,4 +67,66 @@ private String renderAuthSection(User user) { String logoutHtml = readFile("/fragments/nav_logout.html"); return logoutHtml.replace("{{userName}}", user.getName()); } + + private String renderArticleSection(String html, String targetId) { + List
articleList = new ArrayList<>(Database.findAllArticles()); + + if (articleList.isEmpty()) { + logger.info("No articles found in database."); + return html + .replace("{{IMAGE_SECTION}}", "이미지가 없습니다") + .replace("{{ARTICLE_SECTION}}", "게시글이 없습니다") + .replace("{{USERID_SECTION}}", "") + .replace("{{PREV_DISABLED}}", "btn-disabled") + .replace("{{NEXT_DISABLED}}", "btn-disabled"); + } + + int currentIndex = 0; + if (targetId != null) { + boolean found = false; + for (int i = 0; i < articleList.size(); i++) { + if (articleList.get(i).getId().equals(targetId)) { + currentIndex = i; + found = true; + break; + } + } + if (!found) { + throw new IllegalArgumentException("Invalid article ID: " + targetId); + } + } + + Article nowArticle = articleList.get(currentIndex); + + String prevId = "#"; + String nextId = "#"; + String prevDisabled = ""; + String nextDisabled = ""; + + if (currentIndex > 0) { + prevId = articleList.get(currentIndex - 1).getId(); + } else { + prevDisabled = "btn-disabled"; + } + + if (currentIndex < articleList.size() - 1) { + nextId = articleList.get(currentIndex + 1).getId(); + } else { + nextDisabled = "btn-disabled"; + } + + String imageTag = ""; + if (nowArticle.getImagePath() != null && !nowArticle.getImagePath().isEmpty()) { + // DB에 저장된 파일명을 사용하여 img 태그를 생성합니다. + imageTag = ""; + } + + return html.replace("{{USERID_SECTION}}", nowArticle.getAuthorId()) + .replace("{{ARTICLE_SECTION}}", nowArticle.getContent()) + .replace("{{IMAGE_SECTION}}", imageTag) + .replace("{{PREV_ID}}", prevId) + .replace("{{NEXT_ID}}", nextId) + .replace("{{PREV_DISABLED}}", prevDisabled) + .replace("{{NEXT_DISABLED}}", nextDisabled); + } } diff --git a/src/main/java/webserver/controller/RequestMapping.java b/src/main/java/webserver/controller/RequestMapping.java index 60e0850bd..8cbece976 100644 --- a/src/main/java/webserver/controller/RequestMapping.java +++ b/src/main/java/webserver/controller/RequestMapping.java @@ -16,6 +16,7 @@ private record MethodUrlKey(HttpMethod method, String url) { handlerMap.put(new MethodUrlKey(HttpMethod.POST, "/user/login"), new LoginUserController()); handlerMap.put(new MethodUrlKey(HttpMethod.GET, "/index.html"), new IndexController()); handlerMap.put(new MethodUrlKey(HttpMethod.GET, "/"), new IndexController()); + handlerMap.put(new MethodUrlKey(HttpMethod.POST, "/article/new"), new ArticleController()); } public static Controller getController(HttpMethod method, String url) { diff --git a/src/main/resources/static/article/index.html b/src/main/resources/static/article/index.html index 6d2c8eeef..e952012a0 100644 --- a/src/main/resources/static/article/index.html +++ b/src/main/resources/static/article/index.html @@ -23,25 +23,61 @@

게시글 작성

-
+

내용

+ +
+

이미지 첨부

+ +
+ diff --git a/src/main/resources/static/fragments/nav_logout.html b/src/main/resources/static/fragments/nav_logout.html index 845677dd1..664440275 100644 --- a/src/main/resources/static/fragments/nav_logout.html +++ b/src/main/resources/static/fragments/nav_logout.html @@ -1,6 +1,9 @@
  • - {{userName}} 님 + 안녕하세요, {{userName}}님! +
  • +
  • + 글쓰기
  • 로그아웃 -
  • \ No newline at end of file + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index fee1e360d..7b18aa232 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -19,9 +19,9 @@
    - + {{IMAGE_SECTION}}
    • @@ -40,16 +40,7 @@

    - 우리는 시스템 아키텍처에 대한 일관성 있는 접근이 필요하며, 필요한 - 모든 측면은 이미 개별적으로 인식되고 있다고 생각합니다. 즉, 응답이 - 잘 되고, 탄력적이며 유연하고 메시지 기반으로 동작하는 시스템 입니다. - 우리는 이것을 리액티브 시스템(Reactive Systems)라고 부릅니다. - 리액티브 시스템으로 구축된 시스템은 보다 유연하고, 느슨한 결합을 - 갖고, 확장성 이 있습니다. 이로 인해 개발이 더 쉬워지고 변경 사항을 - 적용하기 쉬워집니다. 이 시스템은 장애 에 대해 더 강한 내성을 지니며, - 비록 장애가 발생 하더라도, 재난이 일어나기 보다는 간결한 방식으로 - 해결합니다. 리액티브 시스템은 높은 응답성을 가지며 사용자 에게 - 효과적인 상호적 피드백을 제공합니다. + {{ARTICLE_SECTION}}