게임 등록 추가

This commit is contained in:
pandoli365 2026-05-03 22:25:18 +09:00
parent 39674c7be7
commit 3ed6fcb800
15 changed files with 1368 additions and 71 deletions

3
.gitignore vendored
View File

@ -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/

View File

@ -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<String> 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;
}

View File

@ -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<Resource> 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;
}
}

View File

@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, String>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Path> 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<Path> 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<Path> 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) {
}
}

View File

@ -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<Path> 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<Path> 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;

View File

@ -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<GameData> 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);

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -85,6 +85,8 @@
제작: 김판돌
</p>
<ul class="site-footer__links">
<li><a href="${pageContext.request.contextPath}/terms">이용약관</a></li>
<li><a href="${pageContext.request.contextPath}/operation-policy">운영정책</a></li>
<li><a href="https://x.com/Fursuit_Library" target="_blank" rel="noopener noreferrer">X</a></li>
<li><a href="https://furlib.pandoli365.com" target="_blank" rel="noopener noreferrer">퍼슈트도서관</a></li>
<li><a href="https://gitea.pandoli365.com/pandoli365/bibimbap" target="_blank" rel="noopener noreferrer">소스 코드</a></li>

View File

@ -129,6 +129,54 @@
height: 3rem;
padding: 0 0.875rem;
}
.game-file {
min-height: 3rem;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: var(--field-bg);
color: var(--text);
cursor: pointer;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
.game-file:hover {
border-color: rgba(232, 165, 75, 0.45);
}
.game-file__action {
flex-shrink: 0;
min-height: 2rem;
border-radius: 8px;
padding: 0 0.625rem;
display: inline-flex;
align-items: center;
background: rgba(232, 165, 75, 0.18);
color: var(--text);
font-size: 0.8125rem;
font-weight: 800;
}
.game-file__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
font-size: 0.875rem;
}
.game-file__input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.game-field__textarea {
min-height: 8rem;
resize: vertical;
@ -192,6 +240,11 @@
background: var(--accent);
color: var(--button-text);
}
.game-button:disabled {
cursor: not-allowed;
opacity: 0.58;
box-shadow: none;
}
.game-button svg {
width: 1.125rem;
height: 1.125rem;
@ -300,27 +353,34 @@
<label class="game-field__label" for="game-name">게임 이름</label>
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" required />
</div>
<div class="game-field">
<label class="game-field__label" for="game-creator">제작자</label>
<input class="game-field__input" type="text" id="game-creator" name="creator" maxlength="40" value="<%= creatorValue %>" autocomplete="name" required />
</div>
<div class="game-field">
<label class="game-field__label" for="game-git-url">소스 코드 URL</label>
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" />
</div>
<div class="game-field">
<label class="game-field__label" for="game-webgl-path">WebGL 경로</label>
<input class="game-field__input" type="text" id="game-webgl-path" name="webglPath" placeholder="/webgl/my-game/index.html" />
<label class="game-field__label" for="game-webgl-zip">WebGL zip</label>
<label class="game-file" for="game-webgl-zip">
<span class="game-file__action">zip 선택</span>
<span class="game-file__name" id="game-webgl-file-name">선택된 파일 없음</span>
</label>
<input class="game-file__input" type="file" id="game-webgl-zip" name="webglZip" accept=".zip,application/zip,application/x-zip-compressed" required />
</div>
<div class="game-field">
<label class="game-field__label" for="game-thumbnail-url">썸네일 URL</label>
<input class="game-field__input" type="url" id="game-thumbnail-url" name="thumbnailUrl" placeholder="https://example.com/thumbnail.png" />
<label class="game-field__label" for="game-thumbnail-image">썸네일 이미지</label>
<label class="game-file" for="game-thumbnail-image">
<span class="game-file__action">이미지 선택</span>
<span class="game-file__name" id="game-thumbnail-file-name">선택된 파일 없음</span>
</label>
<input class="game-file__input" type="file" id="game-thumbnail-image" name="thumbnailImage" accept="image/png,image/jpeg,image/webp,image/gif" />
</div>
<div class="game-field game-field--full">
<label class="game-field__label" for="game-creator-note">소개</label>
<textarea class="game-field__textarea" id="game-creator-note" name="creatorNote" maxlength="600"></textarea>
</div>
</div>
<input type="hidden" id="game-uploaded-game-uuid" name="gameUuid" />
<input type="hidden" id="game-uploaded-webgl-path" name="webglPath" />
<input type="hidden" id="game-uploaded-thumbnail-url" name="thumbnailUrl" />
<label class="game-check">
<input type="checkbox" name="visible" value="true" checked />
@ -329,12 +389,12 @@
<div class="game-form__actions">
<a class="game-button" href="<%= ctx %>/">취소</a>
<button class="game-button game-button--primary" type="submit">
<button class="game-button game-button--primary" type="submit" id="game-register-submit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
<span>등록 요청</span>
<span id="game-register-submit-label">등록 요청</span>
</button>
</div>
</form>
@ -343,7 +403,7 @@
<aside class="game-preview" aria-label="미리보기">
<div class="game-preview__media">
<img id="preview-thumb" alt="" hidden />
<img class="game-preview__fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
<img class="game-preview__fallback" id="preview-fallback" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
</div>
<div class="game-preview__body">
<h2 class="game-preview__name" id="preview-name">게임 이름</h2>
@ -356,15 +416,27 @@
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>
(function () {
var ctx = '<%= ctx %>';
var form = document.getElementById('game-register-form');
var nameInput = document.getElementById('game-name');
var creatorInput = document.getElementById('game-creator');
var gitUrlInput = document.getElementById('game-git-url');
var noteInput = document.getElementById('game-creator-note');
var thumbnailInput = document.getElementById('game-thumbnail-url');
var webglInput = document.getElementById('game-webgl-zip');
var thumbnailInput = document.getElementById('game-thumbnail-image');
var webglFileName = document.getElementById('game-webgl-file-name');
var thumbnailFileName = document.getElementById('game-thumbnail-file-name');
var uploadedGameUuid = document.getElementById('game-uploaded-game-uuid');
var uploadedWebglPath = document.getElementById('game-uploaded-webgl-path');
var uploadedThumbnailUrl = document.getElementById('game-uploaded-thumbnail-url');
var visibleInput = form ? form.querySelector('input[name="visible"]') : null;
var submitBtn = document.getElementById('game-register-submit');
var submitLabel = document.getElementById('game-register-submit-label');
var previewName = document.getElementById('preview-name');
var previewCreator = document.getElementById('preview-creator');
var previewNote = document.getElementById('preview-note');
var previewThumb = document.getElementById('preview-thumb');
var previewFallback = document.getElementById('preview-fallback');
var thumbnailPreviewUrl = null;
var submitText = submitLabel ? submitLabel.textContent : '';
function valueOr(input, fallback) {
var value = input && input.value ? input.value.trim() : '';
@ -373,28 +445,158 @@
function updatePreview() {
previewName.textContent = valueOr(nameInput, '게임 이름');
previewCreator.textContent = valueOr(creatorInput, '제작자');
previewNote.textContent = valueOr(noteInput, '소개');
}
var thumbnailUrl = valueOr(thumbnailInput, '');
if (thumbnailUrl) {
previewThumb.src = thumbnailUrl;
function setThumbnailPreview(file) {
if (thumbnailPreviewUrl) {
URL.revokeObjectURL(thumbnailPreviewUrl);
thumbnailPreviewUrl = null;
}
if (file) {
thumbnailPreviewUrl = URL.createObjectURL(file);
previewThumb.src = thumbnailPreviewUrl;
previewThumb.hidden = false;
if (previewFallback) {
previewFallback.hidden = true;
}
} else {
previewThumb.removeAttribute('src');
previewThumb.hidden = true;
if (previewFallback) {
previewFallback.hidden = false;
}
}
}
function openModal(title, message, confirmText, onConfirm) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: title,
message: message,
confirmText: confirmText || '확인',
onConfirm: onConfirm
});
return;
}
alert(message);
if (typeof onConfirm === 'function') {
onConfirm();
}
}
function setSubmitting(submitting) {
if (!submitBtn) return;
submitBtn.disabled = submitting;
if (submitLabel) {
submitLabel.textContent = submitting ? '등록 중...' : submitText;
}
}
function uploadFile(url, file, fields) {
var body = new FormData();
body.append('file', file);
if (fields) {
Object.keys(fields).forEach(function (key) {
body.append(key, fields[key]);
});
}
return fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: body
}).then(function (res) {
return res.json().catch(function () {
return { message: '파일을 업로드하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
var error = new Error(data && data.message ? data.message : '파일을 업로드하지 못했습니다.');
error.status = res.status;
throw error;
}
return data;
});
});
}
function registerGame() {
var body = new URLSearchParams();
body.set('name', valueOr(nameInput, ''));
body.set('gitUrl', valueOr(gitUrlInput, ''));
body.set('creatorNote', valueOr(noteInput, ''));
body.set('gameUuid', valueOr(uploadedGameUuid, ''));
body.set('webglPath', valueOr(uploadedWebglPath, ''));
body.set('thumbnailUrl', valueOr(uploadedThumbnailUrl, ''));
if (visibleInput && visibleInput.checked) {
body.set('visible', visibleInput.value || 'true');
}
return fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: body
}).then(function (res) {
return res.json().catch(function () {
return { message: '게임을 등록하지 못했습니다.' };
}).then(function (data) {
if (!res.ok) {
var error = new Error(data && data.message ? data.message : '게임을 등록하지 못했습니다.');
error.status = res.status;
throw error;
}
return data;
});
});
}
if (nameInput) nameInput.addEventListener('input', updatePreview);
if (creatorInput) creatorInput.addEventListener('input', updatePreview);
if (noteInput) noteInput.addEventListener('input', updatePreview);
if (thumbnailInput) thumbnailInput.addEventListener('input', updatePreview);
if (webglInput) {
webglInput.addEventListener('change', function () {
var file = webglInput.files && webglInput.files[0];
webglFileName.textContent = file ? file.name : '선택된 파일 없음';
if (uploadedWebglPath) {
uploadedWebglPath.value = '';
}
if (uploadedGameUuid) {
uploadedGameUuid.value = '';
}
});
}
if (thumbnailInput) {
thumbnailInput.addEventListener('change', function () {
var file = thumbnailInput.files && thumbnailInput.files[0];
thumbnailFileName.textContent = file ? file.name : '선택된 파일 없음';
if (uploadedThumbnailUrl) {
uploadedThumbnailUrl.value = '';
}
if (file && file.type && file.type.indexOf('image/') === 0) {
setThumbnailPreview(file);
} else {
setThumbnailPreview(null);
if (file) {
thumbnailInput.value = '';
thumbnailFileName.textContent = '선택된 파일 없음';
openModal('썸네일 선택 실패', '이미지 파일만 선택해 주세요.');
}
}
});
}
if (previewThumb) {
previewThumb.addEventListener('error', function () {
previewThumb.removeAttribute('src');
previewThumb.hidden = true;
if (previewFallback) {
previewFallback.hidden = false;
}
});
}
@ -406,15 +608,60 @@
return;
}
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({
title: '저장 준비 중',
message: '저장 로직은 다음 단계에서 연결됩니다.',
confirmText: '확인'
});
} else {
alert('저장 로직은 다음 단계에서 연결됩니다.');
var webglFile = webglInput && webglInput.files ? webglInput.files[0] : null;
var thumbnailFile = thumbnailInput && thumbnailInput.files ? thumbnailInput.files[0] : null;
if (!webglFile) {
openModal('업로드 실패', 'WebGL zip 파일을 선택해 주세요.');
return;
}
setSubmitting(true);
uploadFile(ctx + '/api/game-files/webgl-zip', webglFile).then(function (webglResult) {
if (uploadedGameUuid && webglResult && webglResult.gameUuid) {
uploadedGameUuid.value = webglResult.gameUuid;
}
if (uploadedWebglPath && webglResult && webglResult.webglPath) {
uploadedWebglPath.value = webglResult.webglPath;
}
if (!thumbnailFile) {
return {
webglResult: webglResult,
thumbnailResult: null
};
}
return uploadFile(ctx + '/api/game-files/thumbnail', thumbnailFile, {
gameUuid: webglResult.gameUuid
}).then(function (thumbnailResult) {
return {
webglResult: webglResult,
thumbnailResult: thumbnailResult
};
});
}).then(function (results) {
var thumbnailResult = results.thumbnailResult;
if (uploadedThumbnailUrl && thumbnailResult && thumbnailResult.thumbnailUrl) {
uploadedThumbnailUrl.value = thumbnailResult.thumbnailUrl;
}
return registerGame();
}).then(function (registerResult) {
openModal('게임 등록 완료', '게임 등록이 완료되었습니다.', '확인', function () {
if (registerResult && registerResult.gameId) {
window.location.href = ctx + '/game/' + registerResult.gameId;
} else if (registerResult && registerResult.location) {
window.location.href = ctx + registerResult.location;
} else {
window.location.href = ctx + '/';
}
});
}).catch(function (err) {
openModal('등록 실패', err.message || '게임을 등록하지 못했습니다.', '확인', function () {
if (err.status === 401) {
window.location.href = ctx + '/login';
}
});
}).finally(function () {
setSubmitting(false);
});
});
}

View File

@ -1,9 +1,17 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="com.pandoli365.bibimbap.game.GameCatalog" %>
<%@ page import="com.pandoli365.bibimbap.data.GameData" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %>
<%
String ctx = request.getContextPath();
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
boolean loggedIn = homeSession != null && homeSession.getAttribute("userId") != null;
List<GameData> games = Collections.emptyList();
Object gamesAttr = request.getAttribute("games");
if (gamesAttr instanceof List<?>) {
games = (List<GameData>) gamesAttr;
}
%>
<!DOCTYPE html>
<html lang="ko">
@ -424,6 +432,20 @@
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.home-empty {
grid-column: 1 / -1;
min-height: 11rem;
padding: 2rem 1rem;
border: 1px dashed var(--border);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: var(--card-bg);
text-align: center;
line-height: 1.6;
}
</style>
</head>
<body>
@ -460,17 +482,30 @@
</section>
<section class="card-grid" aria-label="추천 목록">
<%-- 카드 데이터: GameCatalog (DB 연동 시 교체) --%>
<% if (games.isEmpty()) { %>
<div class="home-empty">아직 공개된 게임이 없습니다.<br>로그인 후 첫 게임을 등록해 주세요.</div>
<% } %>
<%
for (int i = 0; i < GameCatalog.COUNT; i++) {
int displayIndex = i + 1;
/* 썸네일 URL — DB 연동 시 null·빈 문자열이면 로고 폴백 */
String thumbUrl = null; // 예: list.get(i).getThumbnailUrl()
boolean hasImage = thumbUrl != null && !thumbUrl.isBlank();
for (int i = 0; i < games.size(); i++) {
GameData game = games.get(i);
if (game == null || game.getId() == null) {
continue;
}
String titleId = "game-title-" + game.getId();
String gameName = HtmlUtils.htmlEscape(game.getName() == null || game.getName().isBlank() ? "제목 없음" : game.getName());
String creator = HtmlUtils.htmlEscape(game.getCreator() == null || game.getCreator().isBlank() ? "bibimbap 사용자" : game.getCreator());
int likeCount = game.getLikeCount() == null ? 0 : game.getLikeCount();
String rawThumbUrl = game.getThumbnailUrl();
boolean hasImage = rawThumbUrl != null && !rawThumbUrl.isBlank();
String thumbUrl = "";
if (hasImage) {
thumbUrl = rawThumbUrl.startsWith("/") ? ctx + rawThumbUrl : rawThumbUrl;
thumbUrl = HtmlUtils.htmlEscape(thumbUrl);
}
%>
<a class="card" href="<%= ctx %>/game/<%= displayIndex %>" aria-labelledby="game-title-<%= i %>">
<a class="card" href="<%= ctx %>/game/<%= game.getId() %>" aria-labelledby="<%= titleId %>">
<div class="card__media">
<span class="card__index" aria-hidden="true">#<%= displayIndex %></span>
<span class="card__index" aria-hidden="true">#<%= game.getId() %></span>
<% if (hasImage) { %>
<img class="card__img" src="<%= thumbUrl %>" alt="" loading="lazy" decoding="async" />
<% } else { %>
@ -480,9 +515,9 @@
<% } %>
</div>
<div class="card__body">
<h2 class="card__game-name" id="game-title-<%= i %>"><%= GameCatalog.NAMES[i] %></h2>
<p class="card__creator"><%= GameCatalog.CREATORS[i] %></p>
<p class="card__likes">좋아요 <%= String.format("%,d", GameCatalog.LIKE_COUNTS[i]) %></p>
<h2 class="card__game-name" id="<%= titleId %>"><%= gameName %></h2>
<p class="card__creator"><%= creator %></p>
<p class="card__likes">좋아요 <%= String.format("%,d", likeCount) %></p>
</div>
</a>
<%

View File

@ -0,0 +1,149 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>운영정책 - bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--border: rgba(0, 0, 0, 0.08);
--panel-shadow: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--panel-shadow: rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.page-main {
max-width: 56rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.policy-doc {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 2px 8px var(--panel-shadow);
overflow: hidden;
}
.policy-doc__head {
padding: 1.5rem 1.35rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.policy-doc__head h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.65rem, 5vw, 2.1rem);
line-height: 1.2;
letter-spacing: -0.02em;
}
.policy-doc__head p {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.6;
}
.policy-doc__body {
padding: 1.35rem;
}
.policy-section + .policy-section {
margin-top: 1.55rem;
padding-top: 1.55rem;
border-top: 1px solid var(--border);
}
.policy-section h2 {
margin: 0 0 0.7rem;
font-size: 1.05rem;
line-height: 1.35;
}
.policy-section p,
.policy-section li {
color: var(--text-muted);
font-size: 0.925rem;
line-height: 1.75;
}
.policy-section p {
margin: 0.45rem 0 0;
}
.policy-section ul {
margin: 0.6rem 0 0;
padding-left: 1.2rem;
}
.policy-section strong {
color: var(--text);
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="page-main">
<article class="policy-doc">
<header class="policy-doc__head">
<h1>운영정책</h1>
<p>시행일: 2026년 5월 3일</p>
</header>
<div class="policy-doc__body">
<section class="policy-section">
<h2>1. 기본 원칙</h2>
<p>bibimbap은 이용자가 직접 만든 게임과 관련 콘텐츠를 안전하고 편안하게 공유하는 공간을 지향합니다. 운영자는 이용자의 창작과 표현을 존중하되, 다른 이용자의 권리와 서비스 안정성을 해치는 행위에는 필요한 조치를 합니다.</p>
</section>
<section class="policy-section">
<h2>2. 게임 등록 기준</h2>
<ul>
<li>게임 제목, 제작자명, 설명, 이미지, 실행 파일 또는 WebGL 경로는 실제 콘텐츠와 관련 있어야 합니다.</li>
<li>타인의 저작물을 사용할 경우 필요한 권한을 확보해야 합니다.</li>
<li>실행 파일, 압축 파일, 스크립트 등에는 악성 코드나 이용자 환경을 훼손하는 동작이 포함되어서는 안 됩니다.</li>
</ul>
</section>
<section class="policy-section">
<h2>3. 댓글 및 커뮤니티 이용</h2>
<ul>
<li>비방, 괴롭힘, 혐오 표현, 개인정보 노출, 도배성 댓글은 제한될 수 있습니다.</li>
<li>버그 제보와 피드백은 가능한 한 구체적이고 존중하는 방식으로 작성해 주세요.</li>
<li>운영자는 분쟁 완화와 서비스 보호를 위해 댓글을 숨김 또는 삭제할 수 있습니다.</li>
</ul>
</section>
<section class="policy-section">
<h2>4. 업로드 파일 관리</h2>
<p>이미지와 게임 파일은 지정된 저장소에 업로드됩니다. 이용자는 업로드한 파일이 본인의 창작물이거나 배포 권한이 있는 자료인지 확인해야 합니다. 운영자는 보안, 용량, 저작권, 정책 위반 문제를 이유로 파일을 삭제하거나 접근을 제한할 수 있습니다.</p>
</section>
<section class="policy-section">
<h2>5. 제재 기준</h2>
<ul>
<li>경미한 위반은 안내, 수정 요청, 콘텐츠 숨김으로 처리할 수 있습니다.</li>
<li>반복 위반 또는 명백한 피해가 있는 경우 콘텐츠 삭제, 업로드 제한, 계정 정지 조치를 할 수 있습니다.</li>
<li>보안 위협, 불법 콘텐츠, 심각한 권리 침해가 확인된 경우 즉시 접근 차단될 수 있습니다.</li>
</ul>
</section>
<section class="policy-section">
<h2>6. 신고 및 문의</h2>
<p>운영정책 위반 콘텐츠, 권리 침해, 계정 문제는 <strong>admin@pandoli365.com</strong>으로 문의할 수 있습니다. 신고 시 문제가 되는 URL, 화면 캡처, 사유를 함께 전달하면 처리가 빨라집니다.</p>
</section>
<section class="policy-section">
<h2>7. 정책 변경</h2>
<p>운영정책은 서비스 상황, 법령, 보안 필요에 따라 변경될 수 있습니다. 중요한 변경이 있는 경우 서비스 내 공지 또는 적절한 방법으로 안내합니다.</p>
</section>
</div>
</article>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>
</html>

View File

@ -0,0 +1,150 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
<title>이용약관 - bibimbap</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--border: rgba(0, 0, 0, 0.08);
--panel-shadow: rgba(0, 0, 0, 0.06);
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--panel-shadow: rgba(0, 0, 0, 0.35);
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
.page-main {
max-width: 56rem;
margin: 0 auto;
padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));
}
.policy-doc {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 2px 8px var(--panel-shadow);
overflow: hidden;
}
.policy-doc__head {
padding: 1.5rem 1.35rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.policy-doc__head h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.65rem, 5vw, 2.1rem);
line-height: 1.2;
letter-spacing: -0.02em;
}
.policy-doc__head p {
margin: 0;
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.6;
}
.policy-doc__body {
padding: 1.35rem;
}
.policy-section + .policy-section {
margin-top: 1.55rem;
padding-top: 1.55rem;
border-top: 1px solid var(--border);
}
.policy-section h2 {
margin: 0 0 0.7rem;
font-size: 1.05rem;
line-height: 1.35;
}
.policy-section p,
.policy-section li {
color: var(--text-muted);
font-size: 0.925rem;
line-height: 1.75;
}
.policy-section p {
margin: 0.45rem 0 0;
}
.policy-section ul {
margin: 0.6rem 0 0;
padding-left: 1.2rem;
}
.policy-section strong {
color: var(--text);
}
</style>
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="page-main">
<article class="policy-doc">
<header class="policy-doc__head">
<h1>이용약관</h1>
<p>시행일: 2026년 5월 3일</p>
</header>
<div class="policy-doc__body">
<section class="policy-section">
<h2>제1조 목적</h2>
<p>본 약관은 bibimbap이 제공하는 게임 게시, 플레이, 댓글, 좋아요 및 관련 서비스의 이용 조건과 절차, 이용자와 운영자의 권리와 의무를 정합니다.</p>
</section>
<section class="policy-section">
<h2>제2조 계정 및 로그인</h2>
<p>이용자는 게스트 계정 또는 외부 로그인 제공자를 통해 서비스를 이용할 수 있습니다. 이용자는 본인의 계정 접근 권한을 안전하게 관리해야 하며, 계정 사용 중 발생한 활동에 대한 책임은 이용자에게 있습니다.</p>
</section>
<section class="policy-section">
<h2>제3조 서비스 이용</h2>
<ul>
<li>이용자는 서비스의 목적과 운영정책에 맞게 게임, 이미지, 댓글 등 콘텐츠를 등록해야 합니다.</li>
<li>운영자는 서비스 품질 유지, 보안, 장애 대응을 위해 필요한 범위에서 서비스 제공 방식을 변경하거나 일시 중단할 수 있습니다.</li>
<li>서비스 내 기능은 개발 상황에 따라 추가, 변경, 제거될 수 있습니다.</li>
</ul>
</section>
<section class="policy-section">
<h2>제4조 콘텐츠 권리</h2>
<p>이용자가 등록한 콘텐츠의 권리는 원칙적으로 해당 이용자에게 있습니다. 다만 이용자는 서비스 내 노출, 저장, 전송, 소개를 위해 필요한 범위에서 운영자에게 콘텐츠를 사용할 수 있는 권한을 부여합니다.</p>
</section>
<section class="policy-section">
<h2>제5조 금지 행위</h2>
<ul>
<li>타인의 권리, 개인정보, 저작권을 침해하는 행위</li>
<li>악성 코드, 비정상 트래픽, 자동화 도구 등으로 서비스를 방해하는 행위</li>
<li>불법적이거나 혐오, 괴롭힘, 과도한 폭력성, 음란성을 포함한 콘텐츠를 게시하는 행위</li>
<li>운영자 또는 다른 이용자를 사칭하거나 허위 정보를 등록하는 행위</li>
</ul>
</section>
<section class="policy-section">
<h2>제6조 이용 제한</h2>
<p>운영자는 약관 또는 운영정책을 위반한 이용자에게 콘텐츠 삭제, 기능 제한, 계정 정지, 접근 차단 등의 조치를 할 수 있습니다. 긴급한 보안 또는 피해 확산 우려가 있는 경우 사전 안내 없이 조치할 수 있습니다.</p>
</section>
<section class="policy-section">
<h2>제7조 책임의 한계</h2>
<p>운영자는 이용자가 등록한 콘텐츠의 완전성, 정확성, 적법성을 보증하지 않습니다. 또한 천재지변, 외부 서비스 장애, 네트워크 문제 등 운영자의 합리적 통제를 벗어난 사유로 발생한 손해에 대해 책임을 지지 않습니다.</p>
</section>
<section class="policy-section">
<h2>제8조 문의</h2>
<p>서비스 이용과 관련한 문의는 <strong>admin@pandoli365.com</strong>으로 보낼 수 있습니다.</p>
</section>
</div>
</article>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>
</html>