diff --git a/src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java b/src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java new file mode 100644 index 0000000..dd8c2ff --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java @@ -0,0 +1,29 @@ +package com.pandoli365.bibimbap.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Path; +import java.nio.file.Paths; + +@Configuration +public class UploadResourceConfig implements WebMvcConfigurer { + + @Value("${app.upload.game-storage-path:src/main/resources/static}") + private String gameStoragePath; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + Path uploadPath = Paths.get(gameStoragePath).toAbsolutePath().normalize(); + String uploadLocation = uploadPath.toUri().toString(); + String profileLocation = uploadPath.resolve("profile").toUri().toString(); + String gameLocation = uploadPath.resolve("game").toUri().toString(); + + registry.addResourceHandler("/profile/**") + .addResourceLocations(profileLocation); + registry.addResourceHandler("/game/**") + .addResourceLocations(uploadLocation, gameLocation); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index 25d431f..eb5577b 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -34,6 +34,14 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { "error", "login", "profile", "signup", "" ); + @GetMapping("/game/new") + public ModelAndView gameRegisterView(HttpSession session) { + if (!isLoggedIn(session)) { + return new ModelAndView("redirect:/login"); + } + return new ModelAndView("/game-register"); + } + @GetMapping("/{pageName}") public ModelAndView mainView(@PathVariable("pageName") String pageName, @RequestParam(name = "id", required = false) String id, @@ -91,4 +99,4 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { // User-Agent 문자열이 모바일 기기를 포함하는지 검사 return Arrays.stream(mobileKeywords).anyMatch(userAgent::contains); } -} \ No newline at end of file +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java new file mode 100644 index 0000000..ef8c043 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java @@ -0,0 +1,100 @@ +package com.pandoli365.bibimbap.controller.api; + +import jakarta.servlet.http.HttpSession; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/game-files") +public class GameUploadController { + + @Value("${app.upload.game-storage-path:src/main/resources/static/game}") + private String gameStoragePath; + + @PostMapping + public ResponseEntity> uploadGameFiles( + @RequestParam("files") MultipartFile[] files, + @RequestParam(name = "path", required = false) String path, + HttpSession session + ) throws IOException { + if (session == null || session.getAttribute("userId") == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "login is required")); + } + if (files == null || files.length == 0) { + return ResponseEntity.badRequest().body(Map.of("message", "files is required")); + } + + Path root = Paths.get(gameStoragePath).toAbsolutePath().normalize(); + List> uploadedFiles = new ArrayList<>(); + for (MultipartFile file : files) { + if (file == null || file.isEmpty()) { + continue; + } + + Path targetFile; + try { + targetFile = resolveTargetFile(root, path, file, files.length); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("message", "invalid path")); + } + + Files.createDirectories(targetFile.getParent()); + Files.copy(file.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING); + String relativePath = root.relativize(targetFile).toString().replace('\\', '/'); + uploadedFiles.add(Map.of( + "fileName", targetFile.getFileName().toString(), + "path", relativePath, + "url", "/game/" + relativePath + )); + } + + Map response = new LinkedHashMap<>(); + response.put("storagePath", root.toString()); + response.put("files", uploadedFiles); + return ResponseEntity.ok(response); + } + + private Path resolveTargetFile(Path root, String path, MultipartFile file, int fileCount) { + String safePath = path == null ? "" : path.trim().replace('\\', '/'); + while (safePath.startsWith("/")) { + safePath = safePath.substring(1); + } + + boolean usePathAsDirectory = safePath.isBlank() || safePath.endsWith("/") || fileCount > 1; + Path targetFile = usePathAsDirectory + ? root.resolve(safePath).resolve(uniqueFileName(file.getOriginalFilename())) + : root.resolve(safePath); + targetFile = targetFile.normalize(); + if (!targetFile.startsWith(root) || targetFile.getParent() == null) { + throw new IllegalArgumentException("invalid path"); + } + return targetFile; + } + + private String uniqueFileName(String originalFileName) { + String cleanName = originalFileName == null ? "file" : Paths.get(originalFileName).getFileName().toString(); + cleanName = cleanName.replaceAll("[^A-Za-z0-9._-]", "_"); + if (cleanName.isBlank() || ".".equals(cleanName) || "..".equals(cleanName)) { + cleanName = "file"; + } + return UUID.randomUUID() + "_" + cleanName; + } +} 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 c01ea18..d09f3d7 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java @@ -6,6 +6,7 @@ import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; import com.pandoli365.bibimbap.mapper.UsersMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -14,9 +15,15 @@ import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.OffsetDateTime; @@ -25,6 +32,7 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; +import java.util.UUID; @Controller public class UserController { @@ -38,11 +46,15 @@ public class UserController { private static final int PASSWORD_SALT_BYTES = 16; private static final int DEFAULT_SESSION_SECONDS = 60 * 30; private static final int REMEMBER_SESSION_SECONDS = 60 * 60 * 24 * 30; + private static final long PROFILE_AVATAR_MAX_BYTES = 5L * 1024 * 1024; private final UsersMapper usersMapper; private final UserAuthIdentitiesMapper userAuthIdentitiesMapper; private final SecureRandom secureRandom = new SecureRandom(); + @Value("${app.upload.game-storage-path:src/main/resources/static}") + private String uploadStoragePath; + public UserController(UsersMapper usersMapper, UserAuthIdentitiesMapper userAuthIdentitiesMapper) { this.usersMapper = usersMapper; this.userAuthIdentitiesMapper = userAuthIdentitiesMapper; @@ -151,6 +163,70 @@ public class UserController { return "redirect:/"; } + @PostMapping("/profile/avatar") + @Transactional + public ResponseEntity updateProfileAvatar( + @RequestParam(name = "avatar", required = false) MultipartFile avatar, + HttpServletRequest request + ) { + HttpSession session = request.getSession(false); + Long userId = sessionUserId(session); + if (userId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new AuthResult(HttpStatus.UNAUTHORIZED.value(), "로그인이 필요합니다.")); + } + if (avatar == null || avatar.isEmpty()) { + return ResponseEntity.badRequest() + .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "프로필 이미지를 선택해 주세요.")); + } + if (avatar.getSize() > PROFILE_AVATAR_MAX_BYTES) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(new AuthResult(HttpStatus.PAYLOAD_TOO_LARGE.value(), "프로필 이미지는 5MB 이하로 올려 주세요.")); + } + + String extension = profileImageExtension(avatar); + if (extension == null) { + return ResponseEntity.badRequest() + .body(new AuthResult(HttpStatus.BAD_REQUEST.value(), "PNG, JPG, WEBP, GIF 이미지만 사용할 수 있습니다.")); + } + + UserData user = usersMapper.getUser(userId); + if (user == null || !STATUS_ACTIVE.equals(user.getStatus())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new AuthResult(HttpStatus.FORBIDDEN.value(), "사용할 수 없는 계정입니다.")); + } + + try { + Path storageRoot = Paths.get(uploadStoragePath).toAbsolutePath().normalize(); + Path profileRoot = storageRoot.resolve("profile").resolve(String.valueOf(userId)).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(); + if (!targetFile.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; + + user.setAvatarUrl(avatarUrl); + usersMapper.updateUser(user); + updateAuthIdentityAvatar(session, avatarUrl); + updateAvatarSession(session, avatarUrl); + + return ResponseEntity.ok(new ProfileAvatarResult(200, "프로필 이미지가 저장되었습니다.", avatarUrl)); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new AuthResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), "프로필 이미지를 저장하지 못했습니다.")); + } + } + private UserData createUser(String displayName, String canonicalEmail, String avatarUrl, OffsetDateTime loginAt) { UserData user = new UserData(); user.setDisplayName(displayName); @@ -194,6 +270,102 @@ public class UserController { return "true".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); } + 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 profileImageExtension(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 void updateAuthIdentityAvatar(HttpSession session, String avatarUrl) { + Object authIdentityId = session.getAttribute("authIdentityId"); + Long identityId = null; + if (authIdentityId instanceof Number number) { + identityId = number.longValue(); + } else if (authIdentityId instanceof String text) { + try { + identityId = Long.parseLong(text); + } catch (NumberFormatException e) { + identityId = null; + } + } + if (identityId == null) { + return; + } + + UserAuthIdentityData identity = userAuthIdentitiesMapper.getUserAuthIdentity(identityId); + if (identity == null) { + return; + } + identity.setAvatarUrl(avatarUrl); + userAuthIdentitiesMapper.updateUserAuthIdentity(identity); + } + + private void updateAvatarSession(HttpSession session, String avatarUrl) { + session.setAttribute("avatarUrl", avatarUrl); + + Object account = session.getAttribute("account"); + if (account instanceof Map existingAccount) { + Map updatedAccount = new LinkedHashMap<>(); + existingAccount.forEach((key, value) -> { + if (key != null) { + updatedAccount.put(String.valueOf(key), value); + } + }); + updatedAccount.put("avatarUrl", avatarUrl); + session.setAttribute("account", updatedAccount); + } + } + private void saveLoginSession(HttpSession session, UserData user, UserAuthIdentityData identity) { session.setAttribute("id", user.getId()); session.setAttribute("userId", user.getId()); @@ -322,4 +494,7 @@ public class UserController { public record AuthResult(int status, String message) { } + + public record ProfileAvatarResult(int status, String message, String avatarUrl) { + } } diff --git a/src/main/resources/dev/db.properties.example b/src/main/resources/dev/db.properties.example index 3abb21f..9faf221 100644 --- a/src/main/resources/dev/db.properties.example +++ b/src/main/resources/dev/db.properties.example @@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=dev spring.datasource.username=your_username spring.datasource.password=your_password + +app.upload.game-storage-path=src/main/resources/static/game diff --git a/src/main/resources/live/db.properties.example b/src/main/resources/live/db.properties.example index 6492ced..b628c06 100644 --- a/src/main/resources/live/db.properties.example +++ b/src/main/resources/live/db.properties.example @@ -2,3 +2,5 @@ spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.url=jdbc:postgresql://localhost:5432/bibimbap?currentSchema=live spring.datasource.username=your_username spring.datasource.password=your_password + +app.upload.game-storage-path=src/main/resources/static/game diff --git a/src/main/resources/static/profile/8/033b3cd5-9534-4c6f-8fcd-982752dd81f9.png b/src/main/resources/static/profile/8/033b3cd5-9534-4c6f-8fcd-982752dd81f9.png new file mode 100644 index 0000000..348470c Binary files /dev/null and b/src/main/resources/static/profile/8/033b3cd5-9534-4c6f-8fcd-982752dd81f9.png differ diff --git a/src/main/resources/static/profile/8/f40b11c2-69bd-412c-b7f8-7b96f8530b12.jpg b/src/main/resources/static/profile/8/f40b11c2-69bd-412c-b7f8-7b96f8530b12.jpg new file mode 100644 index 0000000..8c481b9 Binary files /dev/null and b/src/main/resources/static/profile/8/f40b11c2-69bd-412c-b7f8-7b96f8530b12.jpg differ diff --git a/src/main/webapp/WEB-INF/views/game-register.jsp b/src/main/webapp/WEB-INF/views/game-register.jsp new file mode 100644 index 0000000..9c92652 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/game-register.jsp @@ -0,0 +1,425 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> + +<% + String ctx = request.getContextPath(); + String rawDisplayName = session.getAttribute("displayName") == null ? "" : String.valueOf(session.getAttribute("displayName")); + String creatorValue = HtmlUtils.htmlEscape(rawDisplayName); +%> + + + + + + 신규 게임 개시 | bibimbap + + + + +
+
+

BIBIMBAP GAME

+

신규 게임 개시

+
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ 취소 + +
+
+
+ + +
+
+ + + + diff --git a/src/main/webapp/WEB-INF/views/header.jsp b/src/main/webapp/WEB-INF/views/header.jsp index 6c13361..416bc19 100644 --- a/src/main/webapp/WEB-INF/views/header.jsp +++ b/src/main/webapp/WEB-INF/views/header.jsp @@ -5,6 +5,10 @@ boolean headerLoggedIn = headerSession != null && headerSession.getAttribute("userId") != null; String headerProfileHref = request.getContextPath() + (headerLoggedIn ? "/profile" : "/login"); String headerProfileLabel = headerLoggedIn ? "프로필" : "로그인"; + String headerAvatarUrl = headerSession != null && headerSession.getAttribute("avatarUrl") != null + ? String.valueOf(headerSession.getAttribute("avatarUrl")) + : ""; + boolean headerHasAvatar = headerLoggedIn && !headerAvatarUrl.isBlank(); %> @@ -241,15 +276,16 @@

<%= displayName %>

+
+ + +
@@ -282,5 +318,148 @@ +