게임 등록 추가
This commit is contained in:
parent
39674c7be7
commit
3ed6fcb800
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<%
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue