diff --git a/.gitignore b/.gitignore
index c4e414f..978bea7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,6 @@ build/
### Local secrets ###
src/main/resources/*/db.properties
!src/main/resources/*/db.properties.example
+
+### Test static resources ###
+src/main/resources/static/
diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java
index eb5577b..6377c9e 100644
--- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java
+++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java
@@ -1,5 +1,6 @@
package com.pandoli365.bibimbap.controller;
+import com.pandoli365.bibimbap.mapper.GamesMapper;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@@ -19,6 +20,12 @@ import java.util.List;
@Controller
public class WebMvcController implements WebMvcConfigurer, ErrorController {
+ private final GamesMapper gamesMapper;
+
+ public WebMvcController(GamesMapper gamesMapper) {
+ this.gamesMapper = gamesMapper;
+ }
+
@RequestMapping("/error")
public ModelAndView errorView(HttpServletRequest request) {
ModelAndView mv = new ModelAndView("/errer");
@@ -31,7 +38,7 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
/* ✅ 허용된 페이지 목록 */
private static final List ALLOWED_PAGES = Arrays.asList(
- "error", "login", "profile", "signup", ""
+ "error", "login", "profile", "signup", "terms", "operation-policy", ""
);
@GetMapping("/game/new")
@@ -42,6 +49,11 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
return new ModelAndView("/game-register");
}
+ @GetMapping("/")
+ public ModelAndView indexView() {
+ return indexModelAndView();
+ }
+
@GetMapping("/{pageName}")
public ModelAndView mainView(@PathVariable("pageName") String pageName,
@RequestParam(name = "id", required = false) String id,
@@ -74,14 +86,25 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
case "signup":
mv.setViewName("/signup");
break;
- default:
- mv.setViewName("/index");
+ case "terms":
+ mv.setViewName("/terms");
break;
+ case "operation-policy":
+ mv.setViewName("/operation-policy");
+ break;
+ default:
+ return indexModelAndView();
}
return mv;
}
+ private ModelAndView indexModelAndView() {
+ ModelAndView mv = new ModelAndView("/index");
+ mv.addObject("games", gamesMapper.getVisibleGames());
+ return mv;
+ }
+
private boolean isLoggedIn(HttpSession session) {
return session != null && session.getAttribute("userId") != null;
}
diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java
new file mode 100644
index 0000000..6415586
--- /dev/null
+++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameAssetController.java
@@ -0,0 +1,167 @@
+package com.pandoli365.bibimbap.controller.api;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.util.UriUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.UUID;
+
+@Controller
+public class GameAssetController {
+
+ @Value("${app.upload.game-storage-path:src/main/resources/static}")
+ private String uploadStoragePath;
+
+ @GetMapping("/game/{gameUuid}/**")
+ public ResponseEntity gameAsset(
+ @PathVariable("gameUuid") String gameUuid,
+ HttpServletRequest request
+ ) throws IOException {
+ String normalizedGameUuid = normalizeGameUuid(gameUuid);
+ if (normalizedGameUuid == null) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
+ }
+
+ Path gameDir = gameRoot().resolve(normalizedGameUuid).normalize();
+ Path assetFile = resolveAssetFile(gameDir, normalizedGameUuid, request);
+ if (assetFile == null || !Files.isRegularFile(assetFile)) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
+ }
+
+ HttpHeaders headers = new HttpHeaders();
+ String contentEncoding = contentEncoding(assetFile);
+ String contentType = contentType(assetFile);
+ if (contentEncoding != null) {
+ headers.set(HttpHeaders.CONTENT_ENCODING, contentEncoding);
+ headers.set(HttpHeaders.VARY, HttpHeaders.ACCEPT_ENCODING);
+ }
+ headers.set(HttpHeaders.CONTENT_TYPE, contentType);
+ headers.setCacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic());
+ headers.setContentLength(Files.size(assetFile));
+
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(new FileSystemResource(assetFile));
+ }
+
+ private Path gameRoot() {
+ return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize();
+ }
+
+ private Path resolveAssetFile(Path gameDir, String gameUuid, HttpServletRequest request) {
+ String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+ String requestUri = request.getRequestURI();
+ String prefix = contextPath + "/game/" + gameUuid + "/";
+ if (!requestUri.startsWith(prefix)) {
+ return null;
+ }
+
+ String rawAssetPath = requestUri.substring(prefix.length());
+ String assetPath = UriUtils.decode(rawAssetPath, StandardCharsets.UTF_8)
+ .replace('\\', '/');
+ while (assetPath.startsWith("/")) {
+ assetPath = assetPath.substring(1);
+ }
+ if (assetPath.isBlank() || assetPath.contains("\0")) {
+ return null;
+ }
+
+ Path assetFile = gameDir.resolve(assetPath).normalize();
+ if (!assetFile.startsWith(gameDir)) {
+ return null;
+ }
+ return assetFile;
+ }
+
+ private String normalizeGameUuid(String gameUuid) {
+ if (gameUuid == null || gameUuid.isBlank()) {
+ return null;
+ }
+ try {
+ return UUID.fromString(gameUuid.trim()).toString();
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private String contentEncoding(Path assetFile) {
+ String fileName = assetFile.getFileName().toString().toLowerCase(Locale.ROOT);
+ if (fileName.endsWith(".br")) {
+ return "br";
+ }
+ if (fileName.endsWith(".gz")) {
+ return "gzip";
+ }
+ return null;
+ }
+
+ private String contentType(Path assetFile) throws IOException {
+ String fileName = assetFile.getFileName().toString();
+ String effectiveName = uncompressedFileName(fileName).toLowerCase(Locale.ROOT);
+
+ if (effectiveName.endsWith(".wasm")) {
+ return "application/wasm";
+ }
+ if (effectiveName.endsWith(".js")) {
+ return "application/javascript";
+ }
+ if (effectiveName.endsWith(".data")
+ || effectiveName.endsWith(".mem")
+ || effectiveName.endsWith(".symbols")) {
+ return MediaType.APPLICATION_OCTET_STREAM_VALUE;
+ }
+ if (effectiveName.endsWith(".json")) {
+ return MediaType.APPLICATION_JSON_VALUE;
+ }
+ if (effectiveName.endsWith(".html") || effectiveName.endsWith(".htm")) {
+ return MediaType.TEXT_HTML_VALUE + ";charset=UTF-8";
+ }
+ if (effectiveName.endsWith(".css")) {
+ return "text/css";
+ }
+ if (effectiveName.endsWith(".png")) {
+ return MediaType.IMAGE_PNG_VALUE;
+ }
+ if (effectiveName.endsWith(".jpg") || effectiveName.endsWith(".jpeg")) {
+ return MediaType.IMAGE_JPEG_VALUE;
+ }
+ if (effectiveName.endsWith(".gif")) {
+ return MediaType.IMAGE_GIF_VALUE;
+ }
+ if (effectiveName.endsWith(".webp")) {
+ return "image/webp";
+ }
+ if (effectiveName.endsWith(".svg")) {
+ return "image/svg+xml";
+ }
+
+ String probedType = Files.probeContentType(assetFile);
+ return probedType == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : probedType;
+ }
+
+ private String uncompressedFileName(String fileName) {
+ String lowerName = fileName.toLowerCase(Locale.ROOT);
+ if (lowerName.endsWith(".br") || lowerName.endsWith(".gz")) {
+ int lastDot = fileName.lastIndexOf('.');
+ return lastDot < 0 ? fileName : fileName.substring(0, lastDot);
+ }
+ return fileName;
+ }
+}
diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java
index e40718a..5f7baa8 100644
--- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java
+++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java
@@ -1,33 +1,196 @@
package com.pandoli365.bibimbap.controller.api;
+import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.game.GameCatalog;
-import org.springframework.context.annotation.Configuration;
+import com.pandoli365.bibimbap.mapper.GamesMapper;
+import jakarta.servlet.http.HttpSession;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
-@Configuration
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Controller
public class GameController {
+ private final GamesMapper gamesMapper;
+
+ public GameController(GamesMapper gamesMapper) {
+ this.gamesMapper = gamesMapper;
+ }
public static String webglUrlForGame(int gameId) {
return "/webgl/game-" + gameId + "/index.html";
}
+
+ @PostMapping("/game/new")
+ @Transactional
+ public ResponseEntity