diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..cc1b146880 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -3,8 +3,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -25,12 +28,14 @@ class FileTest { */ @Test void resource_디렉터리에_있는_파일의_경로를_찾는다() { + // given final String fileName = "nextstep.txt"; - // todo - final String actual = ""; + // when + final URL resourceUrl = getClass().getClassLoader().getResource(fileName); - assertThat(actual).endsWith(fileName); + // then + assertThat(resourceUrl.getPath()).endsWith(fileName); } /** @@ -40,15 +45,16 @@ class FileTest { * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws IOException { + // given final String fileName = "nextstep.txt"; + final URL fileUrl = getClass().getClassLoader().getResource(fileName); + final Path path = new File(fileUrl.getPath()).toPath(); - // todo - final Path path = null; - - // todo - final List actual = Collections.emptyList(); + // when + final List actual = Files.readAllLines(path); + // then assertThat(actual).containsOnly("nextstep"); } } diff --git a/study/src/test/java/study/IOStreamTest.java b/study/src/test/java/study/IOStreamTest.java index 47a79356b6..4a05ab26a1 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -4,10 +4,22 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.io.*; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; /** * 자바는 스트림(Stream)으로부터 I/O를 사용한다. @@ -39,23 +51,22 @@ class OutputStream_학습_테스트 { * OutputStream의 서브 클래스(subclass)는 특정 매체에 데이터를 쓰기 위해 write(int b) 메서드를 사용한다. * 예를 들어, FilterOutputStream은 파일로 데이터를 쓸 때, * 또는 DataOutputStream은 자바의 primitive type data를 다른 매체로 데이터를 쓸 때 사용한다. - * + * * write 메서드는 데이터를 바이트로 출력하기 때문에 비효율적이다. * write(byte[] data)write(byte b[], int off, int len) 메서드는 * 1바이트 이상을 한 번에 전송 할 수 있어 훨씬 효율적이다. */ @Test void OutputStream은_데이터를_바이트로_처리한다() throws IOException { + // given final byte[] bytes = {110, 101, 120, 116, 115, 116, 101, 112}; final OutputStream outputStream = new ByteArrayOutputStream(bytes.length); - /** - * todo - * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 - */ + // when + outputStream.write(bytes); + // then final String actual = outputStream.toString(); - assertThat(actual).isEqualTo("nextstep"); outputStream.close(); } @@ -63,7 +74,7 @@ class OutputStream_학습_테스트 { /** * 효율적인 전송을 위해 스트림에서 버퍼링을 사용 할 수 있다. * BufferedOutputStream 필터를 연결하면 버퍼링이 가능하다. - * + * * 버퍼링을 사용하면 OutputStream을 사용할 때 flush를 사용하자. * flush() 메서드는 버퍼가 아직 가득 차지 않은 상황에서 강제로 버퍼의 내용을 전송한다. * Stream은 동기(synchronous)로 동작하기 때문에 버퍼가 찰 때까지 기다리면 @@ -71,14 +82,15 @@ class OutputStream_학습_테스트 { */ @Test void BufferedOutputStream을_사용하면_버퍼링이_가능하다() throws IOException { + // given + final byte[] bytes = {110, 101, 120, 116, 115, 116, 101, 112}; final OutputStream outputStream = mock(BufferedOutputStream.class); - /** - * todo - * flush를 사용해서 테스트를 통과시킨다. - * ByteArrayOutputStream과 어떤 차이가 있을까? - */ + // when + outputStream.write(bytes); + outputStream.flush(); + // then verify(outputStream, atLeastOnce()).flush(); outputStream.close(); } @@ -89,14 +101,15 @@ class OutputStream_학습_테스트 { */ @Test void OutputStream은_사용하고_나서_close_처리를_해준다() throws IOException { - final OutputStream outputStream = mock(OutputStream.class); + // given + final OutputStream outputStream = mock(BufferedOutputStream.class); - /** - * todo - * try-with-resources를 사용한다. - * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. - */ + // when + try (outputStream) { + } + + // then verify(outputStream, atLeastOnce()).close(); } } @@ -108,7 +121,7 @@ class OutputStream_학습_테스트 { * InputStream은 다른 매체로부터 바이트로 데이터를 읽을 때 사용한다. * InputStream의 read() 메서드는 기반 메서드이다. * public abstract int read() throws IOException; - * + * * InputStream의 서브 클래스(subclass)는 특정 매체에 데이터를 읽기 위해 read() 메서드를 사용한다. */ @Nested @@ -121,15 +134,14 @@ class InputStream_학습_테스트 { */ @Test void InputStream은_데이터를_바이트로_읽는다() throws IOException { + // given byte[] bytes = {-16, -97, -92, -87}; final InputStream inputStream = new ByteArrayInputStream(bytes); - /** - * todo - * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? - */ - final String actual = ""; + // when + final String actual = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + // then assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); inputStream.close(); @@ -141,14 +153,14 @@ class InputStream_학습_테스트 { */ @Test void InputStream은_사용하고_나서_close_처리를_해준다() throws IOException { + // given final InputStream inputStream = mock(InputStream.class); - /** - * todo - * try-with-resources를 사용한다. - * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. - */ + // when + try (inputStream) { + } + // then verify(inputStream, atLeastOnce()).close(); } } @@ -169,12 +181,12 @@ class FilterStream_학습_테스트 { * 버퍼 크기를 지정하지 않으면 버퍼의 기본 사이즈는 얼마일까? */ @Test - void 필터인_BufferedInputStream를_사용해보자() { + void 필터인_BufferedInputStream를_사용해보자() throws IOException { final String text = "필터에 연결해보자."; final InputStream inputStream = new ByteArrayInputStream(text.getBytes()); - final InputStream bufferedInputStream = null; + final InputStream bufferedInputStream = new BufferedInputStream(inputStream); - final byte[] actual = new byte[0]; + final byte[] actual = bufferedInputStream.readAllBytes(); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); @@ -197,15 +209,20 @@ class InputStreamReader_학습_테스트 { * 필터인 BufferedReader를 사용하면 readLine 메서드를 사용해서 문자열(String)을 한 줄 씩 읽어올 수 있다. */ @Test - void BufferedReader를_사용하여_문자열을_읽어온다() { + void BufferedReader를_사용하여_문자열을_읽어온다() throws IOException { final String emoji = String.join("\r\n", "😀😃😄😁😆😅😂🤣🥲☺️😊", "😇🙂🙃😉😌😍🥰😘😗😙😚", "😋😛😝😜🤪🤨🧐🤓😎🥸🤩", ""); final InputStream inputStream = new ByteArrayInputStream(emoji.getBytes()); + final InputStreamReader inputStreamReader = new InputStreamReader(inputStream); final StringBuilder actual = new StringBuilder(); + final BufferedReader bufferedReader = new BufferedReader(inputStreamReader); + while (bufferedReader.ready()) { + actual.append(bufferedReader.readLine()).append("\r\n"); + } assertThat(actual).hasToString(emoji); } diff --git a/tomcat/src/main/java/org/apache/coyote/handler/FrontHandler.java b/tomcat/src/main/java/org/apache/coyote/handler/FrontHandler.java new file mode 100644 index 0000000000..06cc2f006d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/FrontHandler.java @@ -0,0 +1,39 @@ +package org.apache.coyote.handler; + +import org.apache.coyote.handler.mapping.HandlerMapping; +import org.apache.coyote.handler.mapping.HomePageMapping; +import org.apache.coyote.handler.mapping.LoginMapping; +import org.apache.coyote.handler.mapping.LoginPageMapping; +import org.apache.coyote.handler.mapping.RegisterMapping; +import org.apache.coyote.handler.mapping.RegisterPageMapping; +import org.apache.coyote.handler.mapping.StaticFileMapping; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.HttpResponse; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +public class FrontHandler { + + private static final Set handlerMapping = new HashSet<>(); + + static { + handlerMapping.add(new HomePageMapping()); + handlerMapping.add(new StaticFileMapping()); + handlerMapping.add(new LoginMapping()); + handlerMapping.add(new LoginPageMapping()); + handlerMapping.add(new RegisterMapping()); + handlerMapping.add(new RegisterPageMapping()); + } + + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + for (final HandlerMapping mapping : handlerMapping) { + if (mapping.supports(httpRequest)) { + return mapping.handle(httpRequest); + } + } + + return HttpResponse.redirect("/404.html"); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/HandlerMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/HandlerMapping.java new file mode 100644 index 0000000000..7a006efb4d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/HandlerMapping.java @@ -0,0 +1,13 @@ +package org.apache.coyote.handler.mapping; + +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.HttpResponse; + +import java.io.IOException; + +public interface HandlerMapping { + + boolean supports(final HttpRequest httpRequest); + + HttpResponse handle(final HttpRequest httpRequest) throws IOException; +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/HomePageMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/HomePageMapping.java new file mode 100644 index 0000000000..a21b4ab1d1 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/HomePageMapping.java @@ -0,0 +1,31 @@ +package org.apache.coyote.handler.mapping; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.ContentType; +import org.apache.coyote.http.response.HttpResponse; +import org.apache.coyote.http.response.StatusCode; +import org.apache.coyote.http.response.StatusLine; + +import java.io.IOException; +import java.util.Map; + +import static org.apache.coyote.http.common.HttpHeader.CONTENT_TYPE; + +public class HomePageMapping implements HandlerMapping { + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isGetRequest() && "/".equals(httpRequest.getRequestUri().getRequestUri()); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.HTML.getValue()))) + .body(new HttpBody("Hello world!")) + .build(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginFilter.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginFilter.java new file mode 100644 index 0000000000..4542493c81 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginFilter.java @@ -0,0 +1,24 @@ +package org.apache.coyote.handler.mapping; + +import org.apache.coyote.http.LoginManager; +import org.apache.coyote.http.session.Session; +import org.apache.coyote.http.session.SessionManager; + +import java.util.Map; + +public abstract class LoginFilter { + + private static final LoginManager loginManager = new SessionManager(); + + protected boolean isAlreadyLogined(final String jSessionId) { + return loginManager.isAlreadyLogined(jSessionId); + } + + protected void setSession(final String jSessionId, final Map sessionData) { + final Session session = new Session(jSessionId); + for (final String key : sessionData.keySet()) { + session.add(key, sessionData.get(key)); + } + loginManager.add(session); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginMapping.java new file mode 100644 index 0000000000..49c4388a85 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginMapping.java @@ -0,0 +1,60 @@ +package org.apache.coyote.handler.mapping; + +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeader; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.HttpResponse; +import org.apache.coyote.http.response.StatusCode; +import org.apache.coyote.http.response.StatusLine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; + +public class LoginMapping extends LoginFilter implements HandlerMapping { + + public static final String TARGET_URI = "login"; + private static final Logger log = LoggerFactory.getLogger(LoginMapping.class); + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isPostRequest() && httpRequest.containsRequestUri(TARGET_URI); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + final Map bodyParams = httpRequest.getParsedBody(); + final String account = bodyParams.get("account"); + final String password = bodyParams.get("password"); + + User user = null; + try { + user = InMemoryUserRepository.findByAccount(account) + .orElseThrow(() -> new IllegalArgumentException("잘못된 계정입니다. 다시 입력해주세요.")); + + if (!user.checkPassword(password)) { + throw new IllegalArgumentException("잘못된 비밀번호입니다. 다시 입력해주세요."); + } + log.info("로그인 성공! user = {}", user); + } catch (final IllegalArgumentException e) { + log.warn("login error = {}", e); + return HttpResponse.redirect("/401.html"); + } + + final UUID uuid = UUID.randomUUID(); + setSession(uuid.toString(), Map.of("account", user.getAccount())); + + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.FOUND)) + .httpHeaders(new HttpHeaders( + Map.of(HttpHeader.LOCATION, "/index.html", + HttpHeader.SET_COOKIE, "JSESSIONID=" + uuid))) + .body(HttpBody.empty()) + .build(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginPageMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginPageMapping.java new file mode 100644 index 0000000000..86ef5c53a3 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/LoginPageMapping.java @@ -0,0 +1,75 @@ +package org.apache.coyote.handler.mapping; + +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.request.HttpCookie; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.ContentType; +import org.apache.coyote.http.response.HttpResponse; +import org.apache.coyote.http.response.StatusCode; +import org.apache.coyote.http.response.StatusLine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.apache.coyote.http.common.HttpHeader.CONTENT_TYPE; +import static org.apache.coyote.http.common.HttpHeader.COOKIE; + +public class LoginPageMapping extends LoginFilter implements HandlerMapping { + + public static final String TARGET_URI = "login"; + private static final Logger log = LoggerFactory.getLogger(LoginPageMapping.class); + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isGetRequest() && httpRequest.containsRequestUri(TARGET_URI); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + if (httpRequest.containsHeader(COOKIE)) { + final HttpCookie cookies = HttpCookie.from(httpRequest.getHeader(COOKIE)); + if (isAlreadyLogined(cookies.get("JSESSIONID"))) { + return HttpResponse.redirect("/index.html"); + } + } + + final String[] parsedRequestUri = httpRequest.getRequestUri().getRequestUri().split("\\?"); + if (httpRequest.getRequestUri().getRequestUri().contains("?")) { + final Map queryStrings = Arrays.stream(parsedRequestUri[1].split("&")) + .map(param -> param.split("=")) + .collect(Collectors.toMap(param -> param[0], param -> param[1])); + + final String account = queryStrings.get("account"); + final String password = queryStrings.get("password"); + + try { + final User user = InMemoryUserRepository.findByAccount(account) + .orElseThrow(() -> new IllegalArgumentException("잘못된 계정입니다. 다시 입력해주세요.")); + + if (!user.checkPassword(password)) { + throw new IllegalArgumentException("잘못된 비밀번호입니다. 다시 입력해주세요."); + } + log.info("로그인 성공! user = {}", user); + } catch (final IllegalArgumentException e) { + log.warn("login error = {}", e); + return HttpResponse.redirect("/401.html"); + } + + return HttpResponse.redirect("/index.html"); + } + + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.HTML.getValue()))) + .body(HttpBody.file("static/login.html")) + .build(); + } + +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterMapping.java new file mode 100644 index 0000000000..8ac2f150b9 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterMapping.java @@ -0,0 +1,33 @@ +package org.apache.coyote.handler.mapping; + +import nextstep.jwp.db.InMemoryUserRepository; +import nextstep.jwp.model.User; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.HttpResponse; + +import java.io.IOException; +import java.util.Map; + +public class RegisterMapping implements HandlerMapping { + + public static final String TARGET_URI = "register"; + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isPostRequest() && httpRequest.containsRequestUri(TARGET_URI); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + final Map bodyParams = httpRequest.getParsedBody(); + + final String account = bodyParams.get("account"); + final String password = bodyParams.get("password"); + final String email = bodyParams.get("email"); + + final User user = new User(account, password, email); + InMemoryUserRepository.save(user); + + return HttpResponse.redirect("/index.html"); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterPageMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterPageMapping.java new file mode 100644 index 0000000000..886e609afd --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/RegisterPageMapping.java @@ -0,0 +1,33 @@ +package org.apache.coyote.handler.mapping; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.ContentType; +import org.apache.coyote.http.response.HttpResponse; +import org.apache.coyote.http.response.StatusCode; +import org.apache.coyote.http.response.StatusLine; + +import java.io.IOException; +import java.util.Map; + +import static org.apache.coyote.http.common.HttpHeader.CONTENT_TYPE; + +public class RegisterPageMapping implements HandlerMapping { + + public static final String TARGET_URI = "register"; + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isGetRequest() && httpRequest.containsRequestUri(TARGET_URI); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.HTML.getValue()))) + .body(HttpBody.file("static/register.html")) + .build(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/handler/mapping/StaticFileMapping.java b/tomcat/src/main/java/org/apache/coyote/handler/mapping/StaticFileMapping.java new file mode 100644 index 0000000000..a38c9d7534 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/handler/mapping/StaticFileMapping.java @@ -0,0 +1,67 @@ +package org.apache.coyote.handler.mapping; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.ContentType; +import org.apache.coyote.http.response.HttpResponse; +import org.apache.coyote.http.response.StatusCode; +import org.apache.coyote.http.response.StatusLine; + +import java.io.IOException; +import java.util.Map; + +import static org.apache.coyote.http.common.HttpHeader.CONTENT_TYPE; + +public class StaticFileMapping implements HandlerMapping { + + @Override + public boolean supports(final HttpRequest httpRequest) { + return httpRequest.isGetRequest() && + (httpRequest.getRequestUri().getRequestUri().endsWith(".html") || + httpRequest.getRequestUri().getRequestUri().endsWith(".js") || + httpRequest.getRequestUri().getRequestUri().endsWith(".css") || + httpRequest.getRequestUri().getRequestUri().endsWith(".ico") + ); + } + + @Override + public HttpResponse handle(final HttpRequest httpRequest) throws IOException { + final String requestUri = httpRequest.getRequestUri().getRequestUri(); + final String filePath = "static" + requestUri; + + if (requestUri.endsWith(".html")) { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.HTML.getValue()))) + .body(HttpBody.file(filePath)) + .build(); + } + + if (requestUri.endsWith(".js")) { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.JS.getValue()))) + .body(HttpBody.file(filePath)) + .build(); + } + + if (requestUri.endsWith(".css")) { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.CSS.getValue()))) + .body(HttpBody.file(filePath)) + .build(); + } + + if (requestUri.endsWith(".ico")) { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.OK)) + .httpHeaders(new HttpHeaders(Map.of(CONTENT_TYPE, ContentType.ICO.getValue()))) + .body(HttpBody.file(filePath)) + .build(); + } + + return null; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/LoginManager.java b/tomcat/src/main/java/org/apache/coyote/http/LoginManager.java new file mode 100644 index 0000000000..cadf666403 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/LoginManager.java @@ -0,0 +1,10 @@ +package org.apache.coyote.http; + +import org.apache.coyote.http.session.Session; + +public interface LoginManager { + + void add(final Session session); + + boolean isAlreadyLogined(final String id); +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/common/HttpBody.java b/tomcat/src/main/java/org/apache/coyote/http/common/HttpBody.java new file mode 100644 index 0000000000..0852cf1d22 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/common/HttpBody.java @@ -0,0 +1,49 @@ +package org.apache.coyote.http.common; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public class HttpBody { + + private static final HttpBody EMPTY = new HttpBody(""); + + private final String value; + + public HttpBody(final String value) { + this.value = value; + } + + public static HttpBody file(final String filePath) throws IOException { + final URL fileUrl = HttpBody.class.getClassLoader().getResource(filePath); + final Path path = new File(fileUrl.getPath()).toPath(); + return new HttpBody(new String(Files.readAllBytes(path))); + } + + public static HttpBody empty() { + return EMPTY; + } + + public Map parseBodyParameters() { + if (value.isBlank()) { + throw new IllegalStateException("Empty body 는 key-value 로 parsing 할 수 없습니다."); + } + + return Arrays.stream(value.split("&")) + .map(param -> param.split("=")) + .collect(Collectors.toMap(param -> param[0], param -> param[1])); + } + + public boolean isEmpty() { + return value.isBlank(); + } + + public String getValue() { + return value; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeader.java b/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeader.java new file mode 100644 index 0000000000..11beda4ad4 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeader.java @@ -0,0 +1,49 @@ +package org.apache.coyote.http.common; + +import java.util.Arrays; + +public enum HttpHeader { + + ACCEPT("Accept"), + ACCEPT_ECCODING("Accept-Encoding"), + ACCEPT_LANGUAGE("Accept-Language"), + CONNECTION("Connection"), + CACHE_CONTROL("Cache-Control"), + CONTENT_TYPE("Content-Type"), + CONTENT_LENGTH("Content-Length"), + COOKIE("Cookie"), + DNT("DNT"), + HOST("Host"), + LOCATION("Location"), + ORIGIN("Origin"), + PRAGMA("Pragma"), + REFERER("Referer"), + SEC_CH_UA("sec-ch-ua"), + SEC_CH_UA_MOBILE("sec-ch-ua-mobile"), + SEC_CH_UA_PLATFORM("sec-ch-ua-platform"), + SEC_FETCH_SITE("Sec-Fetch-Site"), + SEC_FETCH_DEST("Sec-Fetch-Dest"), + SEC_FETCH_MODE("Sec-Fetch-Mode"), + SEC_FETCH_USER("Sec-Fetch-User"), + SET_COOKIE("Set-Cookie"), + UPGRADE_INSECURE_REQUESTS("Upgrade-Insecure-Requests"), + USER_AGENT("User-Agent"), + ; + + private final String value; + + HttpHeader(final String value) { + this.value = value; + } + + public static HttpHeader from(final String target) { + return Arrays.stream(values()) + .filter(httpMethod -> httpMethod.getValue().equalsIgnoreCase(target)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 Header 입니다.")); + } + + public String getValue() { + return value; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeaders.java b/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeaders.java new file mode 100644 index 0000000000..ef7568df97 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/common/HttpHeaders.java @@ -0,0 +1,32 @@ +package org.apache.coyote.http.common; + +import java.util.Map; + +public class HttpHeaders { + + private final Map headers; + + public HttpHeaders(final Map headers) { + this.headers = headers; + } + + public String get(final HttpHeader headerName) { + if (!headers.containsKey(headerName)) { + throw new IllegalStateException("HttpRequest 에 존재하지 않는 HttpHeader 입니다."); + } + + return headers.get(headerName); + } + + public boolean containsKey(final HttpHeader headerName) { + return headers.containsKey(headerName); + } + + public void add(final HttpHeaders httpHeaders) { + headers.putAll(httpHeaders.getHeaders()); + } + + public Map getHeaders() { + return headers; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/common/Protocol.java b/tomcat/src/main/java/org/apache/coyote/http/common/Protocol.java new file mode 100644 index 0000000000..8795cb1537 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/common/Protocol.java @@ -0,0 +1,25 @@ +package org.apache.coyote.http.common; + +import java.util.Arrays; + +public enum Protocol { + + HTTP_1_1("HTTP/1.1"); + + private final String name; + + Protocol(final String name) { + this.name = name; + } + + public static Protocol from(final String target) { + return Arrays.stream(values()) + .filter(protocol -> protocol.name.equalsIgnoreCase(target)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 프로토콜 입니다.")); + } + + public String getName() { + return name; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/request/HttpCookie.java b/tomcat/src/main/java/org/apache/coyote/http/request/HttpCookie.java new file mode 100644 index 0000000000..49c634ba02 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/request/HttpCookie.java @@ -0,0 +1,26 @@ +package org.apache.coyote.http.request; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public class HttpCookie { + + private final Map parsedHttpCookies; + + private HttpCookie(final Map parsedHttpCookies) { + this.parsedHttpCookies = parsedHttpCookies; + } + + public static HttpCookie from(final String rawCookie) { + final Map parseHttpCookies = Arrays.stream(rawCookie.split(";\\s?")) + .map(param -> param.split("=")) + .collect(Collectors.toMap(params -> params[0], params -> params[1])); + + return new HttpCookie(parseHttpCookies); + } + + public String get(final String key) { + return parsedHttpCookies.get(key); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/request/HttpMethod.java b/tomcat/src/main/java/org/apache/coyote/http/request/HttpMethod.java new file mode 100644 index 0000000000..a45024a73b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/request/HttpMethod.java @@ -0,0 +1,15 @@ +package org.apache.coyote.http.request; + +import java.util.Arrays; + +public enum HttpMethod { + GET, + POST; + + public static HttpMethod from(final String target) { + return Arrays.stream(values()) + .filter(httpMethod -> httpMethod.name().equals(target.toUpperCase())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("잘 못된 HTTP 요청입니다.")); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/request/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/http/request/HttpRequest.java new file mode 100644 index 0000000000..3b784d2817 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/request/HttpRequest.java @@ -0,0 +1,48 @@ +package org.apache.coyote.http.request; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeader; +import org.apache.coyote.http.common.HttpHeaders; + +import java.util.Map; + +public class HttpRequest { + + private final RequestLine requestLine; + private final HttpHeaders headers; + private final HttpBody httpBody; + + public HttpRequest(final RequestLine requestLine, final HttpHeaders headers, final HttpBody httpBody) { + this.requestLine = requestLine; + this.headers = headers; + this.httpBody = httpBody; + } + + public boolean isPostRequest() { + return requestLine.isPostMethod(); + } + + public boolean isGetRequest() { + return requestLine.isGetMethod(); + } + + public boolean containsRequestUri(final String uri) { + return requestLine.containsRequestUri(uri); + } + + public boolean containsHeader(final HttpHeader headerName) { + return headers.containsKey(headerName); + } + + public String getHeader(final HttpHeader httpHeader) { + return headers.get(httpHeader); + } + + public Map getParsedBody() { + return httpBody.parseBodyParameters(); + } + + public RequestUri getRequestUri() { + return requestLine.getRequestUri(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/request/RequestLine.java b/tomcat/src/main/java/org/apache/coyote/http/request/RequestLine.java new file mode 100644 index 0000000000..e2377e36a9 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/request/RequestLine.java @@ -0,0 +1,37 @@ +package org.apache.coyote.http.request; + +import org.apache.coyote.http.common.Protocol; + +public class RequestLine { + + private final HttpMethod httpMethod; + private final RequestUri requestUri; + + private final Protocol protocol; + + public RequestLine(final HttpMethod httpMethod, final RequestUri requestUri, final Protocol protocol) { + this.httpMethod = httpMethod; + this.requestUri = requestUri; + this.protocol = protocol; + } + + public HttpMethod getHttpMethod() { + return httpMethod; + } + + public boolean isPostMethod() { + return httpMethod == HttpMethod.POST; + } + + public boolean isGetMethod() { + return httpMethod == HttpMethod.GET; + } + + public boolean containsRequestUri(final String uri) { + return requestUri.contains(uri); + } + + public RequestUri getRequestUri() { + return requestUri; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/request/RequestUri.java b/tomcat/src/main/java/org/apache/coyote/http/request/RequestUri.java new file mode 100644 index 0000000000..3bd4ee9029 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/request/RequestUri.java @@ -0,0 +1,18 @@ +package org.apache.coyote.http.request; + +public class RequestUri { + + private final String requestUri; + + public RequestUri(final String requestUri) { + this.requestUri = requestUri; + } + + public boolean contains(final String uri) { + return requestUri.contains(uri); + } + + public String getRequestUri() { + return requestUri; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/response/ContentType.java b/tomcat/src/main/java/org/apache/coyote/http/response/ContentType.java new file mode 100644 index 0000000000..7a3bf0e35d --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/response/ContentType.java @@ -0,0 +1,20 @@ +package org.apache.coyote.http.response; + +public enum ContentType { + + HTML("text/html;charset=utf-8"), + CSS("text/css;charset=utf-8"), + JS("text/javascript;charset=utf-8"), + ICO("image/x-icon"), + ; + + private final String value; + + ContentType(final String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponse.java b/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponse.java new file mode 100644 index 0000000000..6d307fdef6 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponse.java @@ -0,0 +1,76 @@ +package org.apache.coyote.http.response; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeader; +import org.apache.coyote.http.common.HttpHeaders; + +import java.util.Map; + +public class HttpResponse { + + public static final String SPACE = " "; + public static final String HEADER_DELIMETER = ": "; + public static final String CRLF = "\r\n"; + public static final String BLANK_LINE = ""; + + private final StatusLine statusLine; + private final HttpHeaders httpHeaders; + private final HttpBody body; + + HttpResponse(final StatusLine statusLine, final HttpHeaders httpHeaders, final HttpBody body) { + this.statusLine = statusLine; + this.httpHeaders = httpHeaders; + this.body = body; + } + + public static HttpResponse redirect(final String redirectUri) { + return HttpResponse.builder() + .statusLine(StatusLine.from(StatusCode.FOUND)) + .httpHeaders(new HttpHeaders(Map.of(HttpHeader.LOCATION, redirectUri))) + .body(HttpBody.empty()) + .build(); + } + + public static HttpResponseBuilder builder() { + return new HttpResponseBuilder(); + } + + public String serialize() { + final StringBuilder response = new StringBuilder(); + + serializeStatusLine(response); + serializeHeaders(response); + + if (body == null || body.isEmpty()) { + return response.toString(); + } + + response.append(HttpHeader.CONTENT_LENGTH.getValue()) + .append(HEADER_DELIMETER) + .append(body.getValue().getBytes().length) + .append(SPACE).append(CRLF); + + serializeBody(response); + return response.toString(); + } + + private void serializeStatusLine(final StringBuilder response) { + response.append(statusLine.getProtocol().getName()).append(SPACE); + response.append(statusLine.getStatusCode().getCode()).append(SPACE); + response.append(statusLine.getStatusCode().name()).append(SPACE).append(CRLF); + } + + private void serializeHeaders(final StringBuilder response) { + httpHeaders.getHeaders().forEach((key, value) -> response.append(key.getValue()) + .append(HEADER_DELIMETER) + .append(value) + .append(SPACE) + .append(CRLF) + ); + } + + private void serializeBody(final StringBuilder response) { + response.append(BLANK_LINE).append(CRLF); + response.append(body.getValue()); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponseBuilder.java b/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponseBuilder.java new file mode 100644 index 0000000000..649bd7d8f0 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/response/HttpResponseBuilder.java @@ -0,0 +1,33 @@ +package org.apache.coyote.http.response; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeaders; + +public class HttpResponseBuilder { + + private StatusLine statusLine; + private HttpHeaders httpHeaders; + private HttpBody body; + + HttpResponseBuilder() { + } + + public HttpResponseBuilder statusLine(final StatusLine statusLine) { + this.statusLine = statusLine; + return this; + } + + public HttpResponseBuilder httpHeaders(final HttpHeaders httpHeaders) { + this.httpHeaders = httpHeaders; + return this; + } + + public HttpResponseBuilder body(final HttpBody body) { + this.body = body; + return this; + } + + public HttpResponse build() { + return new HttpResponse(statusLine, httpHeaders, body); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/response/StatusCode.java b/tomcat/src/main/java/org/apache/coyote/http/response/StatusCode.java new file mode 100644 index 0000000000..5eba6b978a --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/response/StatusCode.java @@ -0,0 +1,17 @@ +package org.apache.coyote.http.response; + +public enum StatusCode { + + OK(200), + FOUND(302); + + private final int code; + + StatusCode(final int code) { + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/response/StatusLine.java b/tomcat/src/main/java/org/apache/coyote/http/response/StatusLine.java new file mode 100644 index 0000000000..440f1adf0e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/response/StatusLine.java @@ -0,0 +1,26 @@ +package org.apache.coyote.http.response; + +import org.apache.coyote.http.common.Protocol; + +public class StatusLine { + + private final Protocol protocol; + private final StatusCode statusCode; + + private StatusLine(final Protocol protocol, final StatusCode statusCode) { + this.protocol = protocol; + this.statusCode = statusCode; + } + + public static StatusLine from(final StatusCode statusCode) { + return new StatusLine(Protocol.HTTP_1_1, statusCode); + } + + public Protocol getProtocol() { + return protocol; + } + + public StatusCode getStatusCode() { + return statusCode; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/session/Session.java b/tomcat/src/main/java/org/apache/coyote/http/session/Session.java new file mode 100644 index 0000000000..dc6481726c --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/session/Session.java @@ -0,0 +1,22 @@ +package org.apache.coyote.http.session; + +import java.util.HashMap; +import java.util.Map; + +public class Session { + + private final String id; + private final Map values = new HashMap<>(); + + public Session(final String id) { + this.id = id; + } + + public void add(final String key, final String data) { + values.put(key, data); + } + + public String getId() { + return id; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http/session/SessionManager.java b/tomcat/src/main/java/org/apache/coyote/http/session/SessionManager.java new file mode 100644 index 0000000000..c3dada960c --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http/session/SessionManager.java @@ -0,0 +1,21 @@ +package org.apache.coyote.http.session; + +import org.apache.coyote.http.LoginManager; + +import java.util.HashMap; +import java.util.Map; + +public class SessionManager implements LoginManager { + + private static final Map SESSIONS = new HashMap<>(); + + @Override + public void add(final Session session) { + SESSIONS.put(session.getId(), session); + } + + @Override + public boolean isAlreadyLogined(final String id) { + return SESSIONS.get(id) != null; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java index 7f1b2c7e96..0c290c29a0 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -2,10 +2,15 @@ import nextstep.jwp.exception.UncheckedServletException; import org.apache.coyote.Processor; +import org.apache.coyote.handler.FrontHandler; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.response.HttpResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; import java.net.Socket; public class Http11Processor implements Runnable, Processor { @@ -13,9 +18,13 @@ public class Http11Processor implements Runnable, Processor { private static final Logger log = LoggerFactory.getLogger(Http11Processor.class); private final Socket connection; + private final FrontHandler frontHandler; + private final HttpRequestParser httpRequestParser; public Http11Processor(final Socket connection) { this.connection = connection; + this.frontHandler = new FrontHandler(); + this.httpRequestParser = new HttpRequestParser(); } @Override @@ -27,21 +36,21 @@ public void run() { @Override public void process(final Socket connection) { try (final var inputStream = connection.getInputStream(); - final var outputStream = connection.getOutputStream()) { + final var outputStream = connection.getOutputStream(); + final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + ) { + final HttpRequest httpRequest = httpRequestParser.parseHttpRequest(bufferedReader); + if (httpRequest == null) { + return; + } - final var responseBody = "Hello world!"; + final HttpResponse response = frontHandler.handle(httpRequest); - final var response = String.join("\r\n", - "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", - "Content-Length: " + responseBody.getBytes().length + " ", - "", - responseBody); - - outputStream.write(response.getBytes()); + outputStream.write(response.serialize().getBytes()); outputStream.flush(); } catch (IOException | UncheckedServletException e) { log.error(e.getMessage(), e); } } + } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java new file mode 100644 index 0000000000..d1c7460567 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/HttpRequestParser.java @@ -0,0 +1,71 @@ +package org.apache.coyote.http11; + +import org.apache.coyote.http.common.HttpBody; +import org.apache.coyote.http.common.HttpHeader; +import org.apache.coyote.http.common.HttpHeaders; +import org.apache.coyote.http.common.Protocol; +import org.apache.coyote.http.request.HttpMethod; +import org.apache.coyote.http.request.HttpRequest; +import org.apache.coyote.http.request.RequestLine; +import org.apache.coyote.http.request.RequestUri; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.apache.coyote.http.common.HttpHeader.CONTENT_LENGTH; +import static org.apache.coyote.http.request.HttpMethod.POST; + +public class HttpRequestParser { + + public static final String SPACE = " "; + + public HttpRequestParser() { + } + + public HttpRequest parseHttpRequest(final BufferedReader bufferedReader) throws IOException { + final String firstLine = bufferedReader.readLine(); + if (firstLine == null) { + return null; + } + + final RequestLine requestLine = parseStartLine(firstLine); + final HttpHeaders headers = parseHeader(bufferedReader); + final HttpBody requestBody = parseRequestBody(requestLine.getHttpMethod(), headers, bufferedReader); + + return new HttpRequest(requestLine, headers, requestBody); + } + + private RequestLine parseStartLine(final String startLine) { + final String[] parsedStartLine = startLine.split(SPACE); + final HttpMethod httpMethod = HttpMethod.from(parsedStartLine[0]); + final RequestUri requestUri = new RequestUri(parsedStartLine[1]); + final Protocol httpProtocol = Protocol.from(parsedStartLine[2]); + + return new RequestLine(httpMethod, requestUri, httpProtocol); + } + + private HttpHeaders parseHeader(final BufferedReader bufferedReader) throws IOException { + final Map headers = new HashMap<>(); + String header = bufferedReader.readLine(); + while (!"".equals(header)) { + final String[] parsedHeader = header.split(": "); + headers.put(HttpHeader.from(parsedHeader[0]), parsedHeader[1]); + header = bufferedReader.readLine(); + } + + return new HttpHeaders(headers); + } + + private HttpBody parseRequestBody(final HttpMethod httpMethod, final HttpHeaders headers, final BufferedReader bufferedReader) throws IOException { + if (httpMethod == POST) { + final int contentLength = Integer.parseInt(headers.get(CONTENT_LENGTH)); + char[] buffer = new char[contentLength]; + bufferedReader.read(buffer, 0, contentLength); + return new HttpBody(new String(buffer)); + } + + return HttpBody.empty(); + } +} diff --git a/tomcat/src/main/resources/static/favicon.ico b/tomcat/src/main/resources/static/favicon.ico new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tomcat/src/main/resources/static/login.html b/tomcat/src/main/resources/static/login.html index f4ed9de875..bc933357f2 100644 --- a/tomcat/src/main/resources/static/login.html +++ b/tomcat/src/main/resources/static/login.html @@ -20,7 +20,7 @@

로그인

-
+