diff --git a/src/main/java/app/handler/HomeHandler.java b/src/main/java/app/handler/HomeHandler.java new file mode 100644 index 000000000..480eeb75a --- /dev/null +++ b/src/main/java/app/handler/HomeHandler.java @@ -0,0 +1,20 @@ +package app.handler; + +import http.HttpMethod; +import http.HttpStatus; +import http.request.HttpRequest; +import web.handler.SingleArgHandler; +import web.response.DynamicViewResponse; +import web.response.HandlerResponse; + +public class HomeHandler extends SingleArgHandler { + + public HomeHandler() { + super(HttpMethod.GET, "/"); + } + + @Override + public HandlerResponse handle(HttpRequest request) { + return DynamicViewResponse.of(HttpStatus.OK, "/index.html"); + } +} diff --git a/src/main/java/app/handler/LoginWithPost.java b/src/main/java/app/handler/LoginWithPost.java index feb310ef5..6f64f319d 100644 --- a/src/main/java/app/handler/LoginWithPost.java +++ b/src/main/java/app/handler/LoginWithPost.java @@ -39,16 +39,17 @@ public HandlerResponse handle(QueryParameters params) { SessionEntity session = sessionManager.create( user.getUserId(), - user.getUserRole()); + user.getUserRole(), + user.getNickname()); - RedirectResponse res = RedirectResponse.to("/"); - res.setCookie( + RedirectResponse response = RedirectResponse.to("/"); + response.setCookie( CookieBuilder.of("SID", session.getId()) .path("/") .httpOnly() .sameSite(CookieBuilder.SameSite.LAX) .maxAge(VariableConfig.ABSOLUTE_MS) ); - return res; + return response; } } diff --git a/src/main/java/app/handler/LogoutWithPost.java b/src/main/java/app/handler/LogoutWithPost.java new file mode 100644 index 000000000..7eef24b61 --- /dev/null +++ b/src/main/java/app/handler/LogoutWithPost.java @@ -0,0 +1,28 @@ +package app.handler; + +import http.HttpMethod; +import http.request.HttpRequest; +import http.response.CookieBuilder; +import web.handler.SingleArgHandler; +import web.response.HandlerResponse; +import web.response.RedirectResponse; +import web.session.SessionStorage; + +public class LogoutWithPost extends SingleArgHandler { + private final SessionStorage sessionManager; + + public LogoutWithPost(SessionStorage sessionManager) { + super(HttpMethod.POST, "/user/logout"); + this.sessionManager = sessionManager; + } + + @Override + public HandlerResponse handle(HttpRequest request) { + String sid = request.getCookieValue("SID").orElse(null); + if (sid != null) sessionManager.invalidate(sid); + + RedirectResponse response = RedirectResponse.to("/"); + response.setCookie(CookieBuilder.delete("SID").path("/")); + return response; + } +} diff --git a/src/main/java/app/handler/RegisterWithPost.java b/src/main/java/app/handler/RegisterWithPost.java index f1844c3a1..066683b9b 100644 --- a/src/main/java/app/handler/RegisterWithPost.java +++ b/src/main/java/app/handler/RegisterWithPost.java @@ -11,7 +11,7 @@ import web.filter.authentication.UserRole; import web.handler.SingleArgHandler; import web.response.HandlerResponse; -import web.response.StaticViewResponse; +import web.response.RedirectResponse; public class RegisterWithPost extends SingleArgHandler { private static final Logger log = LoggerFactory.getLogger(RegisterWithPost.class); @@ -27,6 +27,6 @@ public HandlerResponse handle(QueryParameters params) { String password = params.getQueryValue("password").orElseThrow(()-> new ServiceException(ErrorCode.MISSING_REGISTER_TOKEN, "password required")); Database.addUser(new User(password, nickname, email, UserRole.MEMBER.toString())); log.info("Registered - password:{}, nickname:{}, email:{}", password, nickname, email); - return StaticViewResponse.of("/login"); + return RedirectResponse.to("/login"); } } diff --git a/src/main/java/config/AppConfig.java b/src/main/java/config/AppConfig.java index 32545fcea..64c8413d1 100644 --- a/src/main/java/config/AppConfig.java +++ b/src/main/java/config/AppConfig.java @@ -1,8 +1,6 @@ package config; -import app.handler.LoginWithPost; -import app.handler.RegisterWithGet; -import app.handler.RegisterWithPost; +import app.handler.*; import exception.ExceptionHandlerMapping; import exception.handler.ErrorExceptionHandler; import exception.handler.ServiceExceptionHandler; @@ -20,12 +18,17 @@ import web.dispatch.argument.resolver.HttpRequestResolver; import web.dispatch.argument.resolver.QueryParamsResolver; import web.filter.*; +import web.handler.DefaultViewHandler; import web.handler.StaticContentHandler; import web.handler.WebHandler; +import web.renderer.DynamicViewRenderer; import web.renderer.HttpResponseRenderer; import web.renderer.RedirectRenderer; import web.renderer.StaticViewRenderer; +import web.renderer.view.ExpressionResolver; import web.session.SessionStorage; +import web.renderer.view.TemplateEngine; +import web.renderer.view.TemplateLoader; import java.util.List; @@ -84,8 +87,10 @@ public List webHandlerList() { staticContentHandler(), registerWithGet(), registerWithPost(), - loginWithPost() - ) + loginWithPost(), + logoutWithPost(), + homeHandler(), + defaultViewHandler()) ); } @@ -96,6 +101,10 @@ public StaticContentHandler staticContentHandler() { ); } + public DefaultViewHandler defaultViewHandler(){ + return getOrCreate("defaultViewHandler", DefaultViewHandler::new); + } + public RegisterWithGet registerWithGet() { return getOrCreate( "registerWithGet", @@ -115,13 +124,23 @@ public LoginWithPost loginWithPost() { () -> new LoginWithPost(sessionStorage())); } + public LogoutWithPost logoutWithPost(){ + return getOrCreate("logoutWithPost", + () -> new LogoutWithPost(sessionStorage())); + } + + public HomeHandler homeHandler(){ + return getOrCreate("homeHandler", HomeHandler::new); + } + // ===== Renderer ===== public List httpResponseRendererList() { return getOrCreate( "httpResponseRendererList", () -> List.of( staticViewRenderer(), - redirectRenderer() + redirectRenderer(), + dynamicViewRenderer() ) ); } @@ -137,6 +156,24 @@ public RedirectRenderer redirectRenderer() { return getOrCreate("redirectRenderer", RedirectRenderer::new); } + public DynamicViewRenderer dynamicViewRenderer() { + return getOrCreate("dynamicViewRenderer", + () -> new DynamicViewRenderer(templateEngine())); + } + + // ===== ViewEngine ===== + public TemplateEngine templateEngine() { + return getOrCreate("templateEngine", + () -> new TemplateEngine(templateLoader(), expressionResolver())); + } + + public ExpressionResolver expressionResolver(){ + return getOrCreate("expressResolver", ExpressionResolver::new); + } + + public TemplateLoader templateLoader() { + return getOrCreate("templateLoader", TemplateLoader::new); + } // ===== Adapter ===== public List handlerAdapterList() { @@ -164,7 +201,6 @@ public DefaultHandlerAdapter defaultHandlerAdapter() { DefaultHandlerAdapter::new ); } - // ===== Resolver ===== public List> argumentResolverList() { return getOrCreate( diff --git a/src/main/java/config/SecurityConfig.java b/src/main/java/config/SecurityConfig.java index eb45010d2..2c6ccf029 100644 --- a/src/main/java/config/SecurityConfig.java +++ b/src/main/java/config/SecurityConfig.java @@ -2,6 +2,8 @@ import exception.ErrorException; +import java.util.List; + public class SecurityConfig extends SingletonContainer { private final AppConfig appConfig = new AppConfig(); private int callCount; @@ -15,9 +17,9 @@ public void config(){ public void setPaths(){ appConfig.filterChainContainer() .addPath(FilterType.AUTHENTICATED, "/mypage/**") - .addPath(FilterType.LOG_IN, "/user/login") - .addPath(FilterType.ALL, "/user/**") - .addPath(FilterType.PUBLIC, "/**"); + .addPaths(FilterType.LOG_IN, List.of("/user/login", "/login")) + .addPaths(FilterType.PUBLIC, List.of("/", "/home/*")) + .addPath(FilterType.ALL, "/**"); } diff --git a/src/main/java/config/VariableConfig.java b/src/main/java/config/VariableConfig.java index d458a608b..3b74e0c23 100644 --- a/src/main/java/config/VariableConfig.java +++ b/src/main/java/config/VariableConfig.java @@ -6,6 +6,9 @@ public class VariableConfig { public static final List STATIC_RESOURCE_ROOTS = List.of( "./src/main/resources", "./src/main/resources/static"); + public static final List DYNAMIC_RESOURCE_ROOTS = List.of( + "./src/main/resources/templates" + ); public static final long IDLE_MS = 30*60*100; public static final long ABSOLUTE_MS = 180*60*100; diff --git a/src/main/java/exception/handler/ErrorExceptionHandler.java b/src/main/java/exception/handler/ErrorExceptionHandler.java index 79bb821a9..82c0eea19 100644 --- a/src/main/java/exception/handler/ErrorExceptionHandler.java +++ b/src/main/java/exception/handler/ErrorExceptionHandler.java @@ -15,7 +15,7 @@ import java.time.format.DateTimeFormatter; public class ErrorExceptionHandler implements ExceptionHandler { - private final Logger logger = LoggerFactory.getLogger(ErrorException.class); + private final Logger logger = LoggerFactory.getLogger(ErrorExceptionHandler.class); @Override public boolean support(Throwable e) { return e instanceof ErrorException; diff --git a/src/main/java/http/response/HttpResponse.java b/src/main/java/http/response/HttpResponse.java index cd088ab0f..74ab1e2d5 100644 --- a/src/main/java/http/response/HttpResponse.java +++ b/src/main/java/http/response/HttpResponse.java @@ -75,6 +75,12 @@ public void setBody(File file, byte[] body) { setHeader("Content-Length", String.valueOf(body.length)); } + public void redirectTo(String path){ + setStatus(HttpStatus.FOUND); + setHeader("Location", path); + setHeader("Content-Length", "0"); + } + private String guessContentType(File file) { String byName = URLConnection.guessContentTypeFromName(file.getName()); if (byName != null) return byName; diff --git a/src/main/java/web/dispatch/Dispatcher.java b/src/main/java/web/dispatch/Dispatcher.java index 1a6c136ae..337f28f34 100644 --- a/src/main/java/web/dispatch/Dispatcher.java +++ b/src/main/java/web/dispatch/Dispatcher.java @@ -41,7 +41,7 @@ public HttpResponse handle(HttpRequest request, HttpResponse response){ HandlerAdapter adapter = adapterList.stream().filter(ha -> ha.support(handler)) .findFirst().orElseThrow(() -> new ErrorException("DispatcherError: No adapter matched")); - HandlerResponse handlerResponse = adapter.handle(request, handler); + HandlerResponse handlerResponse = adapter.handle(request, handler).postHandling(request, response); HttpResponseRenderer responseHandler = responseHandlerList.stream() .filter(rh -> rh.supports(handlerResponse)) diff --git a/src/main/java/web/filter/AccessLogFilter.java b/src/main/java/web/filter/AccessLogFilter.java index c050d7706..20d71d373 100644 --- a/src/main/java/web/filter/AccessLogFilter.java +++ b/src/main/java/web/filter/AccessLogFilter.java @@ -12,6 +12,7 @@ public class AccessLogFilter implements ServletFilter { @Override public void runFilter(HttpRequest request, HttpResponse response, FilterChainContainer.FilterChainEngine chain) { + request.getOrGenerateRid(); chain.doFilter(); if(request.getAuthenticationInfo()!=null && request.getAuthenticationInfo().getRole().equals(UserRole.MEMBER)) { diff --git a/src/main/java/web/filter/AuthenticationFilter.java b/src/main/java/web/filter/AuthenticationFilter.java index d5310a7f9..2f80dc6f6 100644 --- a/src/main/java/web/filter/AuthenticationFilter.java +++ b/src/main/java/web/filter/AuthenticationFilter.java @@ -3,6 +3,7 @@ import http.HttpStatus; import http.request.HttpRequest; import http.response.HttpResponse; +import web.filter.authentication.AuthenticationInfo; import web.filter.authentication.UnanimousAuthentication; import web.filter.authentication.UserAuthentication; import web.filter.authentication.UserRole; @@ -20,17 +21,17 @@ public AuthenticationFilter(SessionStorage sessionManager) { public void runFilter(HttpRequest request, HttpResponse response, FilterChainContainer.FilterChainEngine chain) { String sid = request.getCookieValue("SID").orElse(null); SessionEntity session = sessionManager.getValid(sid); + AuthenticationInfo authInfo; if (session == null) { - request.setAuthenticationInfo( - UnanimousAuthentication.of()); + authInfo = UnanimousAuthentication.of(); } else{ - request.setAuthenticationInfo( - UserAuthentication.of( - session.getUserId(), - UserRole.valueOf(session.getUserRole()))); + authInfo = UserAuthentication.of( + session.getUserId(), + UserRole.valueOf(session.getUserRole())); + authInfo.addAttribute("nickname",session.getNickname()); } - + request.setAuthenticationInfo(authInfo); chain.doFilter(); } } diff --git a/src/main/java/web/filter/MemberAuthorizationFilter.java b/src/main/java/web/filter/MemberAuthorizationFilter.java index 2fab21dcb..34fd05b75 100644 --- a/src/main/java/web/filter/MemberAuthorizationFilter.java +++ b/src/main/java/web/filter/MemberAuthorizationFilter.java @@ -1,6 +1,5 @@ package web.filter; -import http.HttpStatus; import http.request.HttpRequest; import http.response.HttpResponse; import web.filter.authentication.UserRole; @@ -13,9 +12,7 @@ public void runFilter(HttpRequest request, HttpResponse response, FilterChainCon if(request.getAuthenticationInfo().getRole().equals(UserRole.MEMBER)){ chain.doFilter(); } else { - response.setStatus(HttpStatus.FOUND); - response.setHeader("Location", "/login"); - response.setHeader("Content-Length", "0"); + response.redirectTo("/login"); } } } diff --git a/src/main/java/web/filter/authentication/AuthenticationInfo.java b/src/main/java/web/filter/authentication/AuthenticationInfo.java index 5db196c3d..e3e6bd167 100644 --- a/src/main/java/web/filter/authentication/AuthenticationInfo.java +++ b/src/main/java/web/filter/authentication/AuthenticationInfo.java @@ -4,5 +4,7 @@ public interface AuthenticationInfo { Optional getUserId(); + Optional getAttribute(String key); + void addAttribute(String key, Object value); UserRole getRole(); } diff --git a/src/main/java/web/filter/authentication/UnanimousAuthentication.java b/src/main/java/web/filter/authentication/UnanimousAuthentication.java index 44f15a1fa..a8c9d4d2b 100644 --- a/src/main/java/web/filter/authentication/UnanimousAuthentication.java +++ b/src/main/java/web/filter/authentication/UnanimousAuthentication.java @@ -1,19 +1,38 @@ package web.filter.authentication; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; public class UnanimousAuthentication implements AuthenticationInfo { private final UserRole USER_ROLE = UserRole.UNANIMOUS; + public UnanimousAuthentication() { + this.attributes = new HashMap<>(); + } + + private final Map attributes; + public static UnanimousAuthentication of(){ return new UnanimousAuthentication(); } + @Override + public void addAttribute(String key, Object value) { + attributes.put(key, value); + } + + @Override + public Optional getAttribute(String key) { + return Optional.ofNullable(attributes.get(key)); + } + @Override public Optional getUserId() { return Optional.empty(); } + @Override public UserRole getRole() { return this.USER_ROLE; diff --git a/src/main/java/web/filter/authentication/UserAuthentication.java b/src/main/java/web/filter/authentication/UserAuthentication.java index efe82fd51..f993fd1f0 100644 --- a/src/main/java/web/filter/authentication/UserAuthentication.java +++ b/src/main/java/web/filter/authentication/UserAuthentication.java @@ -1,20 +1,34 @@ package web.filter.authentication; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; public class UserAuthentication implements AuthenticationInfo { private final Long userId; private final UserRole userRole; + private final Map attributes; private UserAuthentication(Long userId, UserRole userRole) { this.userId = userId; this.userRole = userRole; + this.attributes = new HashMap<>(); } public static UserAuthentication of(Long userId, UserRole userRole){ return new UserAuthentication(userId, userRole); } + @Override + public void addAttribute(String key, Object value) { + attributes.put(key, value); + } + + @Override + public Optional getAttribute(String key) { + return Optional.ofNullable(attributes.get(key)); + } + @Override public Optional getUserId() { return Optional.of(userId); diff --git a/src/main/java/web/handler/DefaultViewHandler.java b/src/main/java/web/handler/DefaultViewHandler.java new file mode 100644 index 000000000..88cd4af40 --- /dev/null +++ b/src/main/java/web/handler/DefaultViewHandler.java @@ -0,0 +1,43 @@ +package web.handler; + +import config.VariableConfig; +import exception.ErrorException; +import http.HttpMethod; +import http.HttpStatus; +import http.request.HttpRequest; +import web.response.DynamicViewResponse; +import web.response.HandlerResponse; + +import java.io.File; +import java.util.List; + +public class DefaultViewHandler implements DefaultHandler{ + private final List roots = VariableConfig.DYNAMIC_RESOURCE_ROOTS; + private final HttpMethod method = HttpMethod.GET; + + @Override + public String getPath() { + throw new ErrorException("DynamicViewHandler::getPath should not be called"); + } + + @Override + public HttpMethod getMethod() { + return this.method; + } + @Override + public boolean checkEndpoint(HttpMethod method, String path) { + if(!method.equals(this.method)) return false; + return roots.stream().anyMatch(root ->{ + File requestedFile = new File(root + path); + String indexFilePath = path + (path.endsWith("/") ? "index.html" : "/index.html"); + File indexFile = new File(root + indexFilePath); + return (requestedFile.exists() && requestedFile.isFile()) || (indexFile.exists() && indexFile.isFile()); + }); + } + + @Override + public HandlerResponse handle(HttpRequest request) { + String path = request.getPath() + (request.getPath().endsWith("/") ? "index.html" : "/index.html"); + return DynamicViewResponse.of(HttpStatus.OK, path); + } +} diff --git a/src/main/java/web/renderer/DynamicViewRenderer.java b/src/main/java/web/renderer/DynamicViewRenderer.java new file mode 100644 index 000000000..3ffd55f18 --- /dev/null +++ b/src/main/java/web/renderer/DynamicViewRenderer.java @@ -0,0 +1,34 @@ +package web.renderer; + +import http.response.HttpResponse; +import web.response.DynamicViewResponse; +import web.response.HandlerResponse; +import web.renderer.view.TemplateEngine; + +import java.nio.charset.StandardCharsets; + +public class DynamicViewRenderer implements HttpResponseRenderer { + private final TemplateEngine templateEngine; + + public DynamicViewRenderer(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + @Override + public boolean supports(HandlerResponse response) { + return response instanceof DynamicViewResponse; + } + + @Override + public HttpResponse handle(HttpResponse httpResponse, HandlerResponse handlerResponse) { + DynamicViewResponse dynamicViewResponse = (DynamicViewResponse) handlerResponse; + String path = dynamicViewResponse.getPath(); + String render = templateEngine.render(path, dynamicViewResponse.getModel()); + + httpResponse.setStatus(handlerResponse.getStatus()); + httpResponse.setBody(render.getBytes(StandardCharsets.UTF_8)); + httpResponse.addHeader("Content-Type", "text/html; charset=utf-8"); + handlerResponse.getCookies().forEach(cookie->httpResponse.addHeader("Set-Cookie", cookie)); + return httpResponse; + } +} diff --git a/src/main/java/web/renderer/StaticViewRenderer.java b/src/main/java/web/renderer/StaticViewRenderer.java index 299dba15b..4fbdd9826 100644 --- a/src/main/java/web/renderer/StaticViewRenderer.java +++ b/src/main/java/web/renderer/StaticViewRenderer.java @@ -2,7 +2,6 @@ import config.VariableConfig; import exception.ErrorException; -import http.HttpStatus; import http.response.HttpResponse; import web.response.HandlerResponse; import web.response.StaticViewResponse; diff --git a/src/main/java/web/renderer/view/ExpressionResolver.java b/src/main/java/web/renderer/view/ExpressionResolver.java new file mode 100644 index 000000000..17d234e45 --- /dev/null +++ b/src/main/java/web/renderer/view/ExpressionResolver.java @@ -0,0 +1,42 @@ +package web.renderer.view; + +import java.lang.reflect.Method; +import java.util.Map; + +public class ExpressionResolver { + + public Object resolve(String expr, Map model) { + if (expr == null || expr.isBlank()) return null; + + String[] parts = expr.trim().split("\\."); + Object cur = model.get(parts[0]); + + for (int i = 1; i < parts.length; i++) { + if (cur == null) return null; + cur = getProperty(cur, parts[i]); + } + return cur; + } + + private Object getProperty(Object target, String name) { + if (target instanceof Map m) { + return m.get(name); + } + + Class c = target.getClass(); + + // getXxx() + String getter = "get" + capitalize(name); + try { + Method method = c.getMethod(getter); + return method.invoke(target); + } catch (Exception ignore) {} + + return null; + } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } +} diff --git a/src/main/java/web/renderer/view/HtmlEscaper.java b/src/main/java/web/renderer/view/HtmlEscaper.java new file mode 100644 index 000000000..31057d4b6 --- /dev/null +++ b/src/main/java/web/renderer/view/HtmlEscaper.java @@ -0,0 +1,14 @@ +package web.renderer.view; + +public final class HtmlEscaper { + private HtmlEscaper() {} + + public static String escape(String s) { + if (s == null || s.isEmpty()) return ""; + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"",""") + .replace("'", "'"); + } +} diff --git a/src/main/java/web/renderer/view/TemplateEngine.java b/src/main/java/web/renderer/view/TemplateEngine.java new file mode 100644 index 000000000..66b153509 --- /dev/null +++ b/src/main/java/web/renderer/view/TemplateEngine.java @@ -0,0 +1,213 @@ +package web.renderer.view; + +import exception.ErrorException; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class TemplateEngine { + private final TemplateLoader loader; + private final ExpressionResolver resolver; + + private final ConcurrentHashMap templateCache = new ConcurrentHashMap<>(); + + private static final Pattern INCLUDE = Pattern.compile("\\{\\{>\\s*([^}]+?)\\s*\\}\\}"); + + // 블록 오프닝 태그 패턴 + private static final Pattern IF_OPEN = Pattern.compile("\\{\\{#if(\\d+)\\s+([^}]+)\\}\\}"); + private static final Pattern EACH_OPEN = Pattern.compile("\\{\\{#each(\\d+)\\s+([^}]+)\\}\\}"); + + // 변수 치환 패턴 + private static final Pattern RAW_VAR = Pattern.compile("\\{\\{\\{\\s*([^}]+?)\\s*\\}\\}\\}"); + private static final Pattern VAR = Pattern.compile("\\{\\{\\s*([^}]+?)\\s*\\}\\}"); + + public TemplateEngine(TemplateLoader loader, ExpressionResolver resolver) { + this.loader = loader; + this.resolver = resolver; + } + + public String render(String viewPath, Map model) { + String template = templateCache.computeIfAbsent(viewPath, loader::load); + return renderText(template, model == null ? Map.of() : model); + } + + public void clearCache() { + templateCache.clear(); + } + + private String processIncludes(String s, Map model) { + while (true) { + Matcher m = INCLUDE.matcher(s); + if (!m.find()) break; + + StringBuffer sb = new StringBuffer(); + do { + String includePath = m.group(1).trim(); + + // include는 "렌더 완료된 결과"를 삽입해야 번호(if1 등) 충돌이 없음 + String includedHtml = render(includePath, model); + + m.appendReplacement(sb, Matcher.quoteReplacement(includedHtml)); + } while (m.find()); + + m.appendTail(sb); + s = sb.toString(); + } + return s; + } + + private String renderText(String text, Map model) { + if (text == null) return ""; + + String s = processIncludes(text, model); + + // 1) 블록 처리(더 이상 없을 때까지 반복) + while (true) { + int nextIf = indexOfRegex(s, IF_OPEN); + int nextEach = indexOfRegex(s, EACH_OPEN); + + if (nextIf < 0 && nextEach < 0) break; + + if (nextIf >= 0 && (nextEach < 0 || nextIf < nextEach)) { + s = processIfBlockAt(s, nextIf, model); + } else { + s = processEachBlockAt(s, nextEach, model); + } + } + + // 2) 변수 치환(Triple braces 먼저) + s = replaceRawVars(s, model); + s = replaceVars(s, model); + + return s; + } + + private String processIfBlockAt(String s, int start, Map model) { + Matcher m = IF_OPEN.matcher(s); + if (!m.find(start) || m.start() != start) { + throw new ErrorException("Template parse error: invalid if open tag"); + } + + String num = m.group(1); + String condKey = m.group(2).trim(); + + int openEnd = m.end(); + String closeTag = "{{/if" + num + "}}"; + int closeIdx = s.indexOf(closeTag, openEnd); + if (closeIdx < 0) { + throw new ErrorException("Template parse error: missing " + closeTag); + } + + String body = s.substring(openEnd, closeIdx); + + String elseTag = "{{else" + num + "}}"; + int elsePos = body.indexOf(elseTag); + + String thenPart = (elsePos >= 0) ? body.substring(0, elsePos) : body; + String elsePart = (elsePos >= 0) ? body.substring(elsePos + elseTag.length()) : ""; + + boolean cond = truthy(resolver.resolve(condKey, model)); + String chosen = cond ? thenPart : elsePart; + + String rendered = renderText(chosen, model); + + return s.substring(0, start) + rendered + s.substring(closeIdx + closeTag.length()); + } + + private String processEachBlockAt(String s, int start, Map model) { + Matcher m = EACH_OPEN.matcher(s); + if (!m.find(start) || m.start() != start) { + throw new ErrorException("Template parse error: invalid each open tag"); + } + + String num = m.group(1); + String listKey = m.group(2).trim(); + + int openEnd = m.end(); + String closeTag = "{{/each" + num + "}}"; + int closeIdx = s.indexOf(closeTag, openEnd); + if (closeIdx < 0) { + throw new ErrorException("Template parse error: missing " + closeTag); + } + + String body = s.substring(openEnd, closeIdx); + + Object v = resolver.resolve(listKey, model); + Iterable items = toIterable(v); + + StringBuilder out = new StringBuilder(); + int i = 0; + + if (items != null) { + for (Object item : items) { + Map overlay = new HashMap<>(model); + + // 핵심: 현재 아이템을 this로 제공 (DTO든 Map이든 상관 없음) + overlay.put("this", item); + overlay.put("index", i); + + // (선택) item이 Map이면 기존처럼 바로 {{key}} 접근도 되게 유지할 수 있음 + if (item instanceof Map mapItem) { + @SuppressWarnings("unchecked") + Map itemMap = (Map) mapItem; + overlay.putAll(itemMap); // 키 충돌 가능성은 있음 + } + + out.append(renderText(body, overlay)); + i++; + } + } + + return s.substring(0, start) + out + s.substring(closeIdx + closeTag.length()); + } + + private String replaceRawVars(String s, Map model) { + Matcher m = RAW_VAR.matcher(s); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String key = m.group(1).trim(); + Object v = resolver.resolve(key, model); + String repl = (v == null) ? "" : v.toString(); // raw + m.appendReplacement(sb, Matcher.quoteReplacement(repl)); + } + m.appendTail(sb); + return sb.toString(); + } + + private String replaceVars(String s, Map model) { + Matcher m = VAR.matcher(s); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String expr = m.group(1).trim(); + + Object v = resolver.resolve(expr, model); + String repl = (v == null) ? "" : HtmlEscaper.escape(v.toString()); + m.appendReplacement(sb, Matcher.quoteReplacement(repl)); + } + m.appendTail(sb); + return sb.toString(); + } + + private boolean truthy(Object v) { + if (v == null) return false; + if (v instanceof Boolean b) return b; + if (v instanceof Number n) return n.doubleValue() != 0.0; + if (v instanceof CharSequence cs) return cs.length() > 0; + if (v instanceof Collection c) return !c.isEmpty(); + if (v instanceof Map m) return !m.isEmpty(); + return true; + } + + private Iterable toIterable(Object v) { + if (v == null) return null; + if (v instanceof Iterable it) return it; + return null; // 단순화: 배열/Map 순회는 이번 버전에서 제외 + } + + private int indexOfRegex(String s, Pattern p) { + Matcher m = p.matcher(s); + return m.find() ? m.start() : -1; + } +} diff --git a/src/main/java/web/renderer/view/TemplateLoader.java b/src/main/java/web/renderer/view/TemplateLoader.java new file mode 100644 index 000000000..db1c070c6 --- /dev/null +++ b/src/main/java/web/renderer/view/TemplateLoader.java @@ -0,0 +1,42 @@ +package web.renderer.view; + +import config.VariableConfig; +import exception.ErrorException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Optional; + +public class TemplateLoader { + private final List roots = VariableConfig.DYNAMIC_RESOURCE_ROOTS; + + public String load(String viewPath) { + Path file = resolve(viewPath) + .orElseThrow(() -> new ErrorException("Template not found: " + viewPath)); + + try { + return Files.readString(file, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new ErrorException("Template read error: " + viewPath, e); + } + } + + private Optional resolve(String viewPath) { + String relative = normalize(viewPath); + for (String root : roots) { + Path p = Paths.get(root).resolve(relative).normalize(); + if (Files.exists(p) && Files.isRegularFile(p)) { + return Optional.of(p); + } + } + return Optional.empty(); + } + + private String normalize(String viewPath) { + if (viewPath == null || viewPath.isBlank()) return ""; + return viewPath.startsWith("/") ? viewPath.substring(1) : viewPath; + } +} diff --git a/src/main/java/web/response/DynamicViewResponse.java b/src/main/java/web/response/DynamicViewResponse.java new file mode 100644 index 000000000..f1cce7718 --- /dev/null +++ b/src/main/java/web/response/DynamicViewResponse.java @@ -0,0 +1,53 @@ +package web.response; + +import exception.ErrorException; +import http.HttpStatus; +import http.request.HttpRequest; +import http.response.HttpResponse; +import web.filter.authentication.AuthenticationInfo; +import web.filter.authentication.UserAuthentication; + +import java.util.HashMap; +import java.util.Map; + +public class DynamicViewResponse extends HandlerResponse { + private final String path; + private final Map model; + + protected DynamicViewResponse(HttpStatus status, String path) { + super(status); + this.path = path; + this.model = new HashMap<>(); + } + + public static DynamicViewResponse of (HttpStatus status, String viewPath){ + return new DynamicViewResponse(status, viewPath); + } + + @Override + public HandlerResponse postHandling(HttpRequest request, HttpResponse httpResponse){ + AuthenticationInfo authenticationInfo = request.getAuthenticationInfo(); + if (authenticationInfo instanceof UserAuthentication) { + addModel("userNickname", authenticationInfo.getAttribute("nickname").orElseThrow( + () -> new ErrorException("DynamicViewResponse:: user nickname loading error") + )); + addModel("isLoginUser", true); + + } else { + addModel("isLoginUser", false); + } + return this; + } + + public String getPath() { + return path; + } + + public Map getModel() { + return model; + } + + public void addModel(String key, Object value) { + model.put(key, value); + } +} diff --git a/src/main/java/web/response/HandlerResponse.java b/src/main/java/web/response/HandlerResponse.java index 884d8df86..75e972543 100644 --- a/src/main/java/web/response/HandlerResponse.java +++ b/src/main/java/web/response/HandlerResponse.java @@ -1,6 +1,8 @@ package web.response; import http.HttpStatus; +import http.request.HttpRequest; +import http.response.HttpResponse; import http.response.ResponseCookie; import java.util.ArrayList; @@ -27,4 +29,8 @@ public void setCookie(ResponseCookie cookie){ public List getCookies(){ return this.cookies.stream().map(ResponseCookie::toHeaderValue).toList(); } + + public HandlerResponse postHandling(HttpRequest request, HttpResponse httpResponse){ + return this; + } } diff --git a/src/main/java/web/session/SessionEntity.java b/src/main/java/web/session/SessionEntity.java index 1c27ed117..a2fd7c870 100644 --- a/src/main/java/web/session/SessionEntity.java +++ b/src/main/java/web/session/SessionEntity.java @@ -4,13 +4,15 @@ public class SessionEntity { private final String id; // 세션 아이디(UUID) private final long userId; // DB 키 private final String userRole; + private final String nickname; private final long createdAt; private volatile long lastAccessAt; - public SessionEntity(String id, long userId, String userRole, long now) { + public SessionEntity(String id, long userId, String userRole, String nickname, long now) { this.id = id; this.userId = userId; this.userRole = userRole; + this.nickname = nickname; this.createdAt = now; this.lastAccessAt = now; } @@ -21,6 +23,7 @@ public SessionEntity(String id, long userId, String userRole, long now) { public long getUserId() { return userId; } public String getUserRole() { return userRole; } public long getCreatedAt() { return createdAt; } + public String getNickname() { return nickname; } public long getLastAccessAt() { return lastAccessAt; } } diff --git a/src/main/java/web/session/SessionStorage.java b/src/main/java/web/session/SessionStorage.java index 4dc663ee3..d3728098c 100644 --- a/src/main/java/web/session/SessionStorage.java +++ b/src/main/java/web/session/SessionStorage.java @@ -11,10 +11,10 @@ public class SessionStorage { private final long idleMs = VariableConfig.IDLE_MS; private final long absoluteMs = VariableConfig.ABSOLUTE_MS; - public SessionEntity create(long userId, String userRole) { + public SessionEntity create(long userId, String userRole, String nickname) { long now = System.currentTimeMillis(); String sid = UUID.randomUUID().toString(); - SessionEntity s = new SessionEntity(sid, userId, userRole, now); + SessionEntity s = new SessionEntity(sid, userId, userRole, nickname, now); store.put(sid, s); return s; } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html deleted file mode 100644 index bcb5697a8..000000000 --- a/src/main/resources/static/index.html +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - - - - -
-
- - -
-
-
- - -
-
    -
  • - -
  • -
  • - -
  • -
- -
-

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

-
-
    -
  • -
    - -

    account

    -
    -

    - 군인 또는 군무원이 아닌 국민은 대한민국의 영역안에서는 중대한 - 군사상 기밀·초병·초소·유독음식물공급·포로·군용물에 관한 죄중 - 법률이 정한 경우와 비상계엄이 선포된 경우를 제외하고는 군사법원의 - 재판을 받지 아니한다. -

    -
  • -
  • -
    - -

    account

    -
    -

    - 대통령의 임기연장 또는 중임변경을 위한 헌법개정은 그 헌법개정 제안 - 당시의 대통령에 대하여는 효력이 없다. 민주평화통일자문회의의 - 조직·직무범위 기타 필요한 사항은 법률로 정한다. -

    -
  • -
  • -
    - -

    account

    -
    -

    - 민주평화통일자문회의의 조직·직무범위 기타 필요한 사항은 법률로 - 정한다. -

    -
  • - - - - -
- -
-
- - diff --git a/src/main/resources/static/article/index.html b/src/main/resources/templates/article/index.html similarity index 60% rename from src/main/resources/static/article/index.html rename to src/main/resources/templates/article/index.html index 6d2c8eeef..41de15965 100644 --- a/src/main/resources/static/article/index.html +++ b/src/main/resources/templates/article/index.html @@ -3,24 +3,12 @@ - - + +
-
- - -
+ {{> /layout/header.html}}

게시글 작성

diff --git a/src/main/resources/static/comment/index.html b/src/main/resources/templates/comment/index.html similarity index 60% rename from src/main/resources/static/comment/index.html rename to src/main/resources/templates/comment/index.html index 35df2b252..eca5bb38f 100644 --- a/src/main/resources/static/comment/index.html +++ b/src/main/resources/templates/comment/index.html @@ -3,24 +3,12 @@ - - + +
-
- - -
+ {{> /layout/header.html}}

댓글 작성

diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 000000000..5c6a9545c --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,138 @@ + + + + + + + + + + +
+ {{> /layout/header.html}} + +
+
+ + +
+
    +
  • + +
  • +
  • + +
  • +
+ +
+

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

+
+
    +
  • +
    + +

    account

    +
    +

    + 군인 또는 군무원이 아닌 국민은 대한민국의 영역안에서는 중대한 + 군사상 기밀·초병·초소·유독음식물공급·포로·군용물에 관한 죄중 + 법률이 정한 경우와 비상계엄이 선포된 경우를 제외하고는 군사법원의 + 재판을 받지 아니한다. +

    +
  • +
  • +
    + +

    account

    +
    +

    + 대통령의 임기연장 또는 중임변경을 위한 헌법개정은 그 헌법개정 제안 + 당시의 대통령에 대하여는 효력이 없다. 민주평화통일자문회의의 + 조직·직무범위 기타 필요한 사항은 법률로 정한다. +

    +
  • +
  • +
    + +

    account

    +
    +

    + 민주평화통일자문회의의 조직·직무범위 기타 필요한 사항은 법률로 + 정한다. +

    +
  • + + + + +
+ +
+
+ + diff --git a/src/main/resources/templates/layout/error-popup.html b/src/main/resources/templates/layout/error-popup.html new file mode 100644 index 000000000..7b9516f8a --- /dev/null +++ b/src/main/resources/templates/layout/error-popup.html @@ -0,0 +1,121 @@ + + +
+
+
알림
+
+
+ +
+
+
+ + diff --git a/src/main/resources/templates/layout/header.html b/src/main/resources/templates/layout/header.html new file mode 100644 index 000000000..678006c49 --- /dev/null +++ b/src/main/resources/templates/layout/header.html @@ -0,0 +1,24 @@ +
+ + + +
diff --git a/src/main/resources/static/login/index.html b/src/main/resources/templates/login/index.html similarity index 65% rename from src/main/resources/static/login/index.html rename to src/main/resources/templates/login/index.html index 7c87ea590..481c62717 100644 --- a/src/main/resources/static/login/index.html +++ b/src/main/resources/templates/login/index.html @@ -3,27 +3,15 @@ - - + +
-
- - -
+ {{> /layout/header.html}}

로그인

-
+

이메일

로그인
+ {{> /layout/error-popup.html}} diff --git a/src/main/resources/static/mypage/index.html b/src/main/resources/templates/mypage/index.html similarity index 78% rename from src/main/resources/static/mypage/index.html rename to src/main/resources/templates/mypage/index.html index 7ab744957..6f7db6ee4 100644 --- a/src/main/resources/static/mypage/index.html +++ b/src/main/resources/templates/mypage/index.html @@ -3,24 +3,13 @@ - - + +
-
- - -
+ {{> /layout/header.html}} +

마이페이지

@@ -74,8 +63,10 @@

마이페이지

변경사항 저장 - -
+ +
+
+ {{> /layout/error-popup.html}} diff --git a/src/main/resources/static/registration/index.html b/src/main/resources/templates/registration/index.html similarity index 71% rename from src/main/resources/static/registration/index.html rename to src/main/resources/templates/registration/index.html index 4222dd38a..83ee68c5a 100644 --- a/src/main/resources/static/registration/index.html +++ b/src/main/resources/templates/registration/index.html @@ -3,27 +3,15 @@ - - + +
-
- - -
+ {{> /layout/header.html}}

회원가입

-
+

이메일

회원가입
+ {{> /layout/error-popup.html}} diff --git a/src/test/java/web/handler/StaticContentHandlerTest.java b/src/test/java/web/handler/StaticContentHandlerTest.java index b9675f1a5..5c1cfcdba 100644 --- a/src/test/java/web/handler/StaticContentHandlerTest.java +++ b/src/test/java/web/handler/StaticContentHandlerTest.java @@ -8,7 +8,7 @@ class StaticContentHandlerTest { private final StaticContentHandler handler = new StaticContentHandler(); @Test void support_test(){ - Assertions.assertThat(handler.checkEndpoint(HttpMethod.GET, "/index.html")).isTrue(); + Assertions.assertThat(handler.checkEndpoint(HttpMethod.GET, "/main/index.html")).isTrue(); } } \ No newline at end of file diff --git a/src/test/java/web/handler/response/handler/StaticViewResponseHandlerTest.java b/src/test/java/web/handler/response/handler/StaticViewResponseHandlerTest.java index 705089234..3b337d555 100644 --- a/src/test/java/web/handler/response/handler/StaticViewResponseHandlerTest.java +++ b/src/test/java/web/handler/response/handler/StaticViewResponseHandlerTest.java @@ -25,7 +25,7 @@ void setUp() { @AfterEach void tearDown() throws IOException { - Path index = resourceRoot.resolve("index.html"); + Path index = resourceRoot.resolve("main/index.html"); if (Files.exists(index)) { Files.delete(index); } @@ -33,7 +33,7 @@ void tearDown() throws IOException { @Test void supports_true_when_response_is_StaticContentResponse() { - HandlerResponse response = StaticViewResponse.of("/index.html"); + HandlerResponse response = StaticViewResponse.of("main/index.html"); assertThat(handler.supports(response)).isTrue(); }