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> createGame( + @RequestParam(name = "name", required = false) String name, + @RequestParam(name = "gitUrl", required = false) String gitUrl, + @RequestParam(name = "creatorNote", required = false) String creatorNote, + @RequestParam(name = "webglPath", required = false) String webglPath, + @RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl, + @RequestParam(name = "visible", required = false) String visible, + HttpSession session + ) { + if (sessionUserId(session) == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + String normalizedName = trimToNull(name); + String normalizedWebglPath = trimToNull(webglPath); + if (normalizedName == null || normalizedName.length() > 80) { + return response(HttpStatus.BAD_REQUEST, "게임 이름을 확인해 주세요."); + } + if (!isGameAssetPath(normalizedWebglPath) || !normalizedWebglPath.endsWith("/index.html")) { + return response(HttpStatus.BAD_REQUEST, "WebGL zip 업로드를 먼저 완료해 주세요."); + } + + String normalizedThumbnailUrl = trimToNull(thumbnailUrl); + if (normalizedThumbnailUrl != null && !isGameAssetPath(normalizedThumbnailUrl)) { + return response(HttpStatus.BAD_REQUEST, "썸네일 경로가 올바르지 않습니다."); + } + + String normalizedCreatorNote = trimToEmpty(creatorNote); + if (normalizedCreatorNote.length() > 600) { + return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요."); + } + + GameData game = new GameData(); + game.setName(normalizedName); + game.setCreator(sessionDisplayName(session)); + game.setCreatorNote(normalizedCreatorNote); + game.setGitUrl(trimToEmpty(gitUrl)); + game.setWebglPath(normalizedWebglPath); + game.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl); + game.setVisible(isChecked(visible)); + game.setSortOrder(gamesMapper.nextSortOrder()); + + gamesMapper.addGame(game); + if (game.getId() == null) { + return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임 등록 결과를 확인하지 못했습니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("message", "게임 등록이 완료되었습니다."); + body.put("gameId", game.getId()); + body.put("location", "/game/" + game.getId()); + return ResponseEntity.ok(body); + } + @GetMapping("/game/{id}") - public String gameDetail(@PathVariable("id") int id, Model model) { - if (!GameCatalog.isValidId(id)) { + public String gameDetail(@PathVariable("id") long id, Model model) { + GameData game = gamesMapper.getGame(id); + if (game != null) { + addGameModel(model, game); + return "game-detail"; + } + + if (id < Integer.MIN_VALUE || id > Integer.MAX_VALUE || !GameCatalog.isValidId((int) id)) { return "redirect:/"; } - int idx = GameCatalog.toIndex(id); - model.addAttribute("gameId", id); + + int intId = (int) id; + int idx = GameCatalog.toIndex(intId); + model.addAttribute("gameId", intId); model.addAttribute("gameName", GameCatalog.NAMES[idx]); model.addAttribute("creator", GameCatalog.CREATORS[idx]); model.addAttribute("likeCount", GameCatalog.LIKE_COUNTS[idx]); model.addAttribute("likeCountFormatted", String.format("%,d", GameCatalog.LIKE_COUNTS[idx])); model.addAttribute("creatorNote", GameCatalog.CREATOR_NOTES[idx]); model.addAttribute("gitUrl", GameCatalog.GIT_URLS[idx]); - model.addAttribute("webglUrl", webglUrlForGame(id)); - model.addAttribute("webglDeployPath", webglUrlForGame(id)); + model.addAttribute("webglUrl", webglUrlForGame(intId)); + model.addAttribute("webglDeployPath", webglUrlForGame(intId)); return "game-detail"; } + + private void addGameModel(Model model, GameData game) { + int likeCount = game.getLikeCount() == null ? 0 : game.getLikeCount(); + String webglPath = trimToEmpty(game.getWebglPath()); + model.addAttribute("gameId", game.getId()); + model.addAttribute("gameName", game.getName()); + model.addAttribute("creator", game.getCreator()); + model.addAttribute("likeCount", likeCount); + model.addAttribute("likeCountFormatted", String.format("%,d", likeCount)); + model.addAttribute("creatorNote", trimToEmpty(game.getCreatorNote())); + model.addAttribute("gitUrl", trimToEmpty(game.getGitUrl())); + model.addAttribute("webglUrl", webglPath); + model.addAttribute("webglDeployPath", webglPath); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private String sessionDisplayName(HttpSession session) { + String displayName = sessionValue(session, "displayName"); + if (displayName != null) { + return displayName; + } + String email = sessionValue(session, "email"); + return email == null ? "bibimbap 사용자" : email; + } + + private String sessionValue(HttpSession session, String name) { + if (session == null) { + return null; + } + Object value = session.getAttribute(name); + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isBlank() ? null : text; + } + + private boolean isChecked(String value) { + return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); + } + + private boolean isGameAssetPath(String path) { + return path != null && path.startsWith("/game/") && !path.contains(".."); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isBlank() ? null : text; + } + + private String trimToEmpty(String value) { + String text = trimToNull(value); + return text == null ? "" : text; + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java index ef8c043..24b8c96 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java @@ -11,22 +11,32 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; @RestController @RequestMapping("/api/game-files") public class GameUploadController { - @Value("${app.upload.game-storage-path:src/main/resources/static/game}") - private String gameStoragePath; + private static final long WEBGL_EXTRACTED_MAX_BYTES = 512L * 1024 * 1024; + private static final int WEBGL_MAX_ENTRIES = 8_000; + private static final long THUMBNAIL_MAX_BYTES = 10L * 1024 * 1024; + + @Value("${app.upload.game-storage-path:src/main/resources/static}") + private String uploadStoragePath; @PostMapping public ResponseEntity> uploadGameFiles( @@ -34,7 +44,7 @@ public class GameUploadController { @RequestParam(name = "path", required = false) String path, HttpSession session ) throws IOException { - if (session == null || session.getAttribute("userId") == null) { + if (sessionUserId(session) == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("message", "login is required")); } @@ -42,7 +52,7 @@ public class GameUploadController { return ResponseEntity.badRequest().body(Map.of("message", "files is required")); } - Path root = Paths.get(gameStoragePath).toAbsolutePath().normalize(); + Path root = gameRoot(); List> uploadedFiles = new ArrayList<>(); for (MultipartFile file : files) { if (file == null || file.isEmpty()) { @@ -72,6 +82,288 @@ public class GameUploadController { return ResponseEntity.ok(response); } + @PostMapping("/webgl-zip") + public ResponseEntity> uploadWebglZip( + @RequestParam(name = "file", required = false) MultipartFile file, + HttpSession session + ) throws IOException { + Long userId = sessionUserId(session); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "로그인이 필요합니다.")); + } + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip 파일을 선택해 주세요.")); + } + if (!isZipFile(file)) { + return ResponseEntity.badRequest().body(Map.of("message", "zip 파일만 업로드할 수 있습니다.")); + } + + Path root = gameRoot(); + String gameUuid = UUID.randomUUID().toString(); + Path targetDir = root.resolve(gameUuid).normalize(); + if (!targetDir.startsWith(root)) { + return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다.")); + } + + try { + Files.createDirectories(targetDir); + ExtractResult extractResult = extractZip(file, targetDir); + Path indexFile = findIndexFile(targetDir); + if (indexFile == null) { + deleteRecursively(targetDir); + return ResponseEntity.badRequest().body(Map.of("message", "zip 안에서 index.html을 찾지 못했습니다.")); + } + + String webglPath = "/game/" + root.relativize(indexFile).toString().replace('\\', '/'); + String deployPath = "/game/" + root.relativize(targetDir).toString().replace('\\', '/'); + Map response = new LinkedHashMap<>(); + response.put("status", 200); + response.put("message", "WebGL 파일이 압축 해제되었습니다."); + response.put("gameUuid", gameUuid); + response.put("webglPath", webglPath); + response.put("deployPath", deployPath); + response.put("entryCount", extractResult.entryCount()); + response.put("extractedBytes", extractResult.extractedBytes()); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + deleteRecursively(targetDir); + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } catch (IOException e) { + deleteRecursively(targetDir); + throw e; + } + } + + @PostMapping("/thumbnail") + public ResponseEntity> uploadThumbnail( + @RequestParam(name = "file", required = false) MultipartFile file, + @RequestParam(name = "gameUuid", required = false) String gameUuid, + HttpSession session + ) throws IOException { + Long userId = sessionUserId(session); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "로그인이 필요합니다.")); + } + if (file == null || file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "썸네일 이미지를 선택해 주세요.")); + } + if (file.getSize() > THUMBNAIL_MAX_BYTES) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(Map.of("message", "썸네일 이미지는 10MB 이하로 올려 주세요.")); + } + + String extension = imageExtension(file); + if (extension == null) { + return ResponseEntity.badRequest() + .body(Map.of("message", "PNG, JPG, WEBP, GIF 이미지만 사용할 수 있습니다.")); + } + + String normalizedGameUuid = normalizeGameUuid(gameUuid); + if (normalizedGameUuid == null) { + return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip을 먼저 업로드해 주세요.")); + } + + Path root = gameRoot(); + Path targetDir = root.resolve(normalizedGameUuid).normalize(); + Path targetFile = targetDir.resolve("thumbnail" + extension).normalize(); + if (!targetDir.startsWith(root) || !targetFile.startsWith(targetDir)) { + return ResponseEntity.badRequest().body(Map.of("message", "저장 경로가 올바르지 않습니다.")); + } + if (!Files.isDirectory(targetDir)) { + return ResponseEntity.badRequest().body(Map.of("message", "WebGL zip을 먼저 업로드해 주세요.")); + } + + deleteExistingThumbnail(targetDir); + Files.copy(file.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING); + String thumbnailUrl = "/game/" + root.relativize(targetFile).toString().replace('\\', '/'); + + Map response = new LinkedHashMap<>(); + response.put("status", 200); + response.put("message", "썸네일 이미지가 저장되었습니다."); + response.put("thumbnailUrl", thumbnailUrl); + return ResponseEntity.ok(response); + } + + private Path gameRoot() { + return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize(); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private boolean isZipFile(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType != null) { + String normalized = contentType.toLowerCase(Locale.ROOT); + if ("application/zip".equals(normalized) + || "application/x-zip-compressed".equals(normalized) + || "multipart/x-zip".equals(normalized)) { + return true; + } + } + String originalName = file.getOriginalFilename(); + return originalName != null && originalName.toLowerCase(Locale.ROOT).endsWith(".zip"); + } + + private ExtractResult extractZip(MultipartFile zipFile, Path targetDir) throws IOException { + long extractedBytes = 0; + int entryCount = 0; + + try (ZipInputStream zipInput = new ZipInputStream(zipFile.getInputStream())) { + ZipEntry entry; + while ((entry = zipInput.getNextEntry()) != null) { + entryCount++; + if (entryCount > WEBGL_MAX_ENTRIES) { + throw new IllegalArgumentException("zip 안의 파일 수가 너무 많습니다."); + } + + Path target = targetDir.resolve(entry.getName()).normalize(); + if (!target.startsWith(targetDir)) { + throw new IllegalArgumentException("zip 안에 올바르지 않은 경로가 포함되어 있습니다."); + } + + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + if (target.getParent() == null) { + throw new IllegalArgumentException("zip 안에 올바르지 않은 파일이 포함되어 있습니다."); + } + Files.createDirectories(target.getParent()); + extractedBytes += copyZipEntry(zipInput, target, extractedBytes); + } + zipInput.closeEntry(); + } + } + + if (entryCount == 0) { + throw new IllegalArgumentException("비어 있는 zip 파일입니다."); + } + return new ExtractResult(entryCount, extractedBytes); + } + + private long copyZipEntry(InputStream input, Path target, long bytesBeforeEntry) throws IOException { + byte[] buffer = new byte[8192]; + long copied = 0; + try (var output = Files.newOutputStream(target)) { + int read; + while ((read = input.read(buffer)) != -1) { + copied += read; + if (bytesBeforeEntry + copied > WEBGL_EXTRACTED_MAX_BYTES) { + throw new IllegalArgumentException("압축 해제된 파일 크기가 너무 큽니다."); + } + output.write(buffer, 0, read); + } + } + return copied; + } + + private Path findIndexFile(Path targetDir) throws IOException { + try (Stream paths = Files.walk(targetDir)) { + return paths + .filter(Files::isRegularFile) + .filter(path -> "index.html".equalsIgnoreCase(path.getFileName().toString())) + .min(Comparator + .comparingInt((Path path) -> targetDir.relativize(path).getNameCount()) + .thenComparing(path -> targetDir.relativize(path).toString())) + .orElse(null); + } + } + + private String imageExtension(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType != null) { + switch (contentType.toLowerCase(Locale.ROOT)) { + case "image/png": + return ".png"; + case "image/jpeg": + case "image/jpg": + return ".jpg"; + case "image/webp": + return ".webp"; + case "image/gif": + return ".gif"; + default: + break; + } + } + + String originalName = file.getOriginalFilename(); + if (originalName == null) { + return null; + } + String cleanName = Paths.get(originalName).getFileName().toString().toLowerCase(Locale.ROOT); + if (cleanName.endsWith(".png")) { + return ".png"; + } + if (cleanName.endsWith(".jpg") || cleanName.endsWith(".jpeg")) { + return ".jpg"; + } + if (cleanName.endsWith(".webp")) { + return ".webp"; + } + if (cleanName.endsWith(".gif")) { + return ".gif"; + } + return null; + } + + 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 void deleteExistingThumbnail(Path targetDir) throws IOException { + try (Stream paths = Files.list(targetDir)) { + for (Path path : paths.toList()) { + String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (Files.isRegularFile(path) && isThumbnailFile(fileName)) { + Files.deleteIfExists(path); + } + } + } + } + + private boolean isThumbnailFile(String fileName) { + return fileName.equals("thumbnail.png") + || fileName.equals("thumbnail.jpg") + || fileName.equals("thumbnail.webp") + || fileName.equals("thumbnail.gif"); + } + + private void deleteRecursively(Path path) throws IOException { + if (!Files.exists(path)) { + return; + } + try (Stream paths = Files.walk(path)) { + for (Path target : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(target); + } + } + } + private Path resolveTargetFile(Path root, String path, MultipartFile file, int fileCount) { String safePath = path == null ? "" : path.trim().replace('\\', '/'); while (safePath.startsWith("/")) { @@ -97,4 +389,7 @@ public class GameUploadController { } return UUID.randomUUID() + "_" + cleanName; } + + private record ExtractResult(int entryCount, long extractedBytes) { + } } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java index d09f3d7..1cbba08 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -29,10 +29,11 @@ import java.security.SecureRandom; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Base64; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; -import java.util.UUID; +import java.util.stream.Stream; @Controller public class UserController { @@ -198,22 +199,28 @@ public class UserController { try { Path storageRoot = Paths.get(uploadStoragePath).toAbsolutePath().normalize(); - Path profileRoot = storageRoot.resolve("profile").resolve(String.valueOf(userId)).normalize(); + Path profileRoot = storageRoot.resolve("profile").normalize(); if (!profileRoot.startsWith(storageRoot)) { return ResponseEntity.badRequest() .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "저장 경로가 올바르지 않습니다.")); } Files.createDirectories(profileRoot); - String fileName = UUID.randomUUID() + extension; - Path targetFile = profileRoot.resolve(fileName).normalize(); + Path targetFile = profileRoot.resolve(userId + extension).normalize(); + Path tempFile = profileRoot.resolve(userId + ".upload" + extension).normalize(); if (!targetFile.startsWith(profileRoot)) { return ResponseEntity.badRequest() .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "저장 경로가 올바르지 않습니다.")); } + if (!tempFile.startsWith(profileRoot)) { + return ResponseEntity.badRequest() + .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "저장 경로가 올바르지 않습니다.")); + } - Files.copy(avatar.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING); - String avatarUrl = "/profile/" + userId + "/" + fileName; + Files.copy(avatar.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING); + deleteExistingProfileAvatars(profileRoot, userId); + Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING); + String avatarUrl = "/profile/" + userId + extension; user.setAvatarUrl(avatarUrl); usersMapper.updateUser(user); @@ -326,6 +333,39 @@ public class UserController { return null; } + private void deleteExistingProfileAvatars(Path profileRoot, Long userId) throws IOException { + if (!Files.exists(profileRoot)) { + return; + } + + String baseName = userId + "."; + try (Stream paths = Files.list(profileRoot)) { + for (Path path : paths.toList()) { + String fileName = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (Files.isRegularFile(path) && isProfileAvatarFile(fileName, baseName)) { + Files.deleteIfExists(path); + } + } + } + + Path legacyProfileDir = profileRoot.resolve(String.valueOf(userId)).normalize(); + if (!legacyProfileDir.startsWith(profileRoot) || !Files.isDirectory(legacyProfileDir)) { + return; + } + try (Stream paths = Files.walk(legacyProfileDir)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + + private boolean isProfileAvatarFile(String fileName, String baseName) { + return fileName.equals(baseName + "png") + || fileName.equals(baseName + "jpg") + || fileName.equals(baseName + "webp") + || fileName.equals(baseName + "gif"); + } + private void updateAuthIdentityAvatar(HttpSession session, String avatarUrl) { Object authIdentityId = session.getAttribute("authIdentityId"); Long identityId = null; diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 190dbed..9383ea8 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -7,6 +7,8 @@ import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface GamesMapper { @@ -29,6 +31,26 @@ public interface GamesMapper { """) GameData getGame(long id); + @Select(""" + SELECT + id, + name, + creator, + creator_note AS creatorNote, + git_url AS gitUrl, + webgl_path AS webglPath, + thumbnail_url AS thumbnailUrl, + like_count AS likeCount, + is_visible AS visible, + sort_order AS sortOrder, + created_at AS createdAt, + updated_at AS updatedAt + FROM games + WHERE is_visible IS NOT FALSE + ORDER BY sort_order ASC, created_at DESC, id DESC + """) + List getVisibleGames(); + @Insert(""" INSERT INTO games ( name, @@ -37,6 +59,7 @@ public interface GamesMapper { git_url, webgl_path, thumbnail_url, + is_visible, sort_order ) VALUES ( #{name}, @@ -45,12 +68,19 @@ public interface GamesMapper { #{gitUrl}, #{webglPath}, #{thumbnailUrl}, + #{visible}, #{sortOrder} ) """) @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") int addGame(GameData game); + @Select(""" + SELECT COALESCE(MAX(sort_order), 0) + 1 + FROM games + """) + int nextSortOrder(); + @Update(""" UPDATE games SET @@ -61,7 +91,7 @@ public interface GamesMapper { webgl_path = #{webglPath}, thumbnail_url = #{thumbnailUrl}, is_visible = #{visible}, - sort_order = #{sortOrder}, + sort_order = #{sortOrder} WHERE id = #{id} """) int updateGame(GameData game); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index eba4df6..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,7 +0,0 @@ -spring.application.name=bibimbap - -spring.mvc.view.prefix=/WEB-INF/views/ -spring.mvc.view.suffix=.jsp - -spring.profiles.active=dev -spring.config.import=optional:classpath:dev/application.properties diff --git a/src/main/resources/dev/application.properties b/src/main/resources/dev/application.properties index f6a375e..984167f 100644 --- a/src/main/resources/dev/application.properties +++ b/src/main/resources/dev/application.properties @@ -12,7 +12,7 @@ spring.config.import=classpath:dev/db.properties server.address=0.0.0.0 # encoding -server.servlet.encoding.force-response=true +server.servlet.encoding.force-response=false # file upload Max Size spring.servlet.multipart.max-file-size=100MB diff --git a/src/main/resources/live/application.properties b/src/main/resources/live/application.properties index 6c6c0f0..962ccc7 100644 --- a/src/main/resources/live/application.properties +++ b/src/main/resources/live/application.properties @@ -12,7 +12,7 @@ spring.config.import=classpath:live/db.properties server.address=0.0.0.0 # encoding -server.servlet.encoding.force-response=true +server.servlet.encoding.force-response=false # file upload Max Size spring.servlet.multipart.max-file-size=100MB diff --git a/src/main/webapp/WEB-INF/views/footer.jsp b/src/main/webapp/WEB-INF/views/footer.jsp index 868dc66..fc156bf 100644 --- a/src/main/webapp/WEB-INF/views/footer.jsp +++ b/src/main/webapp/WEB-INF/views/footer.jsp @@ -85,6 +85,8 @@ 제작: 김판돌