페이지 개선
This commit is contained in:
parent
2cca6c72a6
commit
d88cfbf5fb
|
|
@ -29,17 +29,41 @@ CREATE TABLE IF NOT EXISTS "recruit_posts" (
|
|||
|
||||
ALTER SEQUENCE "recruit_posts_id_seq" OWNED BY "recruit_posts"."id";
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_role_check"
|
||||
CHECK ("role" IN ('기획', '아트', '프로그래머'));
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_role_check'
|
||||
) THEN
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_role_check"
|
||||
CHECK ("role" IN ('기획', '아트', '프로그래머'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_participation_type_check"
|
||||
CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼'));
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'recruit_posts_participation_type_check'
|
||||
) THEN
|
||||
ALTER TABLE "recruit_posts"
|
||||
ADD CONSTRAINT "recruit_posts_participation_type_check"
|
||||
CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_visible_order"
|
||||
ON "recruit_posts" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.pandoli365.bibimbap.controller;
|
|||
|
||||
import com.pandoli365.bibimbap.data.RecruitPostData;
|
||||
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
|
@ -57,8 +59,12 @@ public class RecruitController {
|
|||
@RequestParam(name = "team", required = false) String teamMembers,
|
||||
@RequestParam(name = "contact", required = false) String contact,
|
||||
@RequestParam(name = "description", required = false) String description,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ public class GameAssetController {
|
|||
@Value("${app.upload.game-storage-path:src/main/resources/static}")
|
||||
private String uploadStoragePath;
|
||||
|
||||
@Value("${app.webgl.frame-ancestors:self}")
|
||||
private String webglFrameAncestors;
|
||||
|
||||
@GetMapping("/game/{gameUuid}/**")
|
||||
public void gameAsset(
|
||||
@PathVariable("gameUuid") String gameUuid,
|
||||
|
|
@ -53,6 +56,14 @@ public class GameAssetController {
|
|||
}
|
||||
response.setHeader(HttpHeaders.CONTENT_TYPE, contentType);
|
||||
response.setHeader(HttpHeaders.CACHE_CONTROL, "max-age=3600, public");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; " +
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; " +
|
||||
"connect-src 'self' blob: data:; worker-src 'self' blob:; media-src 'self' blob: data:; " +
|
||||
"object-src 'none'; frame-ancestors " + frameAncestors()
|
||||
);
|
||||
response.setContentLengthLong(Files.size(assetFile));
|
||||
Files.copy(assetFile, response.getOutputStream());
|
||||
}
|
||||
|
|
@ -61,6 +72,14 @@ public class GameAssetController {
|
|||
return Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize();
|
||||
}
|
||||
|
||||
private String frameAncestors() {
|
||||
String value = webglFrameAncestors == null ? "" : webglFrameAncestors.trim();
|
||||
if (value.isBlank() || "self".equalsIgnoreCase(value)) {
|
||||
return "'self'";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Path resolveAssetFile(Path gameDir, String gameUuid, HttpServletRequest request) {
|
||||
String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
String requestUri = request.getRequestURI();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ package com.pandoli365.bibimbap.controller.api;
|
|||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.game.GameCatalog;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
|
@ -16,6 +19,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
|
|
@ -23,6 +27,9 @@ public class GameController {
|
|||
|
||||
private final GamesMapper gamesMapper;
|
||||
|
||||
@Value("${app.webgl.asset-origin:}")
|
||||
private String webglAssetOrigin;
|
||||
|
||||
public GameController(GamesMapper gamesMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
}
|
||||
|
|
@ -40,8 +47,12 @@ public class GameController {
|
|||
@RequestParam(name = "webglPath", required = false) String webglPath,
|
||||
@RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl,
|
||||
@RequestParam(name = "visible", required = false) String visible,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
|
|
@ -70,7 +81,7 @@ public class GameController {
|
|||
game.setUserId(userId);
|
||||
game.setName(normalizedName);
|
||||
game.setCreatorNote(normalizedCreatorNote);
|
||||
game.setGitUrl(trimToEmpty(gitUrl));
|
||||
game.setGitUrl(safeExternalUrl(gitUrl));
|
||||
game.setWebglPath(normalizedWebglPath);
|
||||
game.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl);
|
||||
game.setVisible(isChecked(visible));
|
||||
|
|
@ -109,8 +120,9 @@ public class GameController {
|
|||
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("gitUrl", safeExternalUrl(GameCatalog.GIT_URLS[idx]));
|
||||
model.addAttribute("webglUrl", webglUrlForGame(intId));
|
||||
model.addAttribute("webglFrameSrc", webglFrameSrc(webglUrlForGame(intId)));
|
||||
model.addAttribute("webglDeployPath", webglUrlForGame(intId));
|
||||
model.addAttribute("owner", false);
|
||||
return "game-detail";
|
||||
|
|
@ -153,8 +165,12 @@ public class GameController {
|
|||
@RequestParam(name = "webglPath", required = false) String webglPath,
|
||||
@RequestParam(name = "thumbnailUrl", required = false) String thumbnailUrl,
|
||||
@RequestParam(name = "visible", required = false) String visible,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
|
|
@ -189,7 +205,7 @@ public class GameController {
|
|||
|
||||
existing.setName(normalizedName);
|
||||
existing.setCreatorNote(normalizedCreatorNote);
|
||||
existing.setGitUrl(trimToEmpty(gitUrl));
|
||||
existing.setGitUrl(safeExternalUrl(gitUrl));
|
||||
existing.setWebglPath(normalizedWebglPath);
|
||||
existing.setThumbnailUrl(normalizedThumbnailUrl == null ? "" : normalizedThumbnailUrl);
|
||||
existing.setVisible(isChecked(visible));
|
||||
|
|
@ -205,7 +221,12 @@ public class GameController {
|
|||
|
||||
@DeleteMapping("/game/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deleteGame(@PathVariable("id") long id, HttpSession session) {
|
||||
public ResponseEntity<Map<String, Object>> deleteGame(@PathVariable("id") long id,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
|
|
@ -239,12 +260,34 @@ public class GameController {
|
|||
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("gitUrl", safeExternalUrl(game.getGitUrl()));
|
||||
model.addAttribute("webglUrl", webglPath);
|
||||
model.addAttribute("webglFrameSrc", webglFrameSrc(webglPath));
|
||||
model.addAttribute("webglDeployPath", webglPath);
|
||||
model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId()));
|
||||
}
|
||||
|
||||
private String webglFrameSrc(String path) {
|
||||
String normalizedPath = trimToEmpty(path);
|
||||
String origin = trimToEmpty(webglAssetOrigin);
|
||||
if (origin.isBlank()) {
|
||||
return normalizedPath;
|
||||
}
|
||||
while (origin.endsWith("/")) {
|
||||
origin = origin.substring(0, origin.length() - 1);
|
||||
}
|
||||
return origin + (normalizedPath.startsWith("/") ? normalizedPath : "/" + normalizedPath);
|
||||
}
|
||||
|
||||
private String safeExternalUrl(String value) {
|
||||
String text = trimToNull(value);
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
String lower = text.toLowerCase(Locale.ROOT);
|
||||
return lower.startsWith("http://") || lower.startsWith("https://") ? text : "";
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.pandoli365.bibimbap.controller.api;
|
||||
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -51,8 +53,12 @@ public class GameUploadController {
|
|||
public ResponseEntity<Map<String, Object>> uploadGameFiles(
|
||||
@RequestParam("files") MultipartFile[] files,
|
||||
@RequestParam(name = "path", required = false) String path,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) throws IOException {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (sessionUserId(session) == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("message", "login is required"));
|
||||
|
|
@ -94,8 +100,12 @@ public class GameUploadController {
|
|||
@PostMapping("/webgl-zip")
|
||||
public ResponseEntity<Map<String, Object>> uploadWebglZip(
|
||||
@RequestParam(name = "file", required = false) MultipartFile file,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) throws IOException {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
log.info("WebGL zip upload request received. fileName={}, size={}, contentType={}",
|
||||
file == null ? null : file.getOriginalFilename(),
|
||||
file == null ? null : file.getSize(),
|
||||
|
|
@ -154,8 +164,12 @@ public class GameUploadController {
|
|||
public ResponseEntity<Map<String, Object>> uploadThumbnail(
|
||||
@RequestParam(name = "file", required = false) MultipartFile file,
|
||||
@RequestParam(name = "gameUuid", required = false) String gameUuid,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) throws IOException {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.UserAuthIdentityData;
|
|||
import com.pandoli365.bibimbap.data.UserData;
|
||||
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
|
@ -159,8 +160,13 @@ public class UserController {
|
|||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public String logout(HttpSession session) {
|
||||
session.invalidate();
|
||||
public String logout(HttpServletRequest request, HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
if (session != null) {
|
||||
session.invalidate();
|
||||
}
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +176,9 @@ public class UserController {
|
|||
@RequestParam(name = "displayName", required = false) String displayName,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new AuthResult(403, "요청 보안 토큰이 유효하지 않습니다."));
|
||||
}
|
||||
HttpSession session = request.getSession(false);
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
|
|
@ -203,6 +212,9 @@ public class UserController {
|
|||
@RequestParam(name = "avatar", required = false) MultipartFile avatar,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new AuthResult(403, "요청 보안 토큰이 유효하지 않습니다."));
|
||||
}
|
||||
HttpSession session = request.getSession(false);
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ server.tomcat.max-swallow-size=-1
|
|||
|
||||
# common
|
||||
spring.config.import=optional:classpath:${spring.profiles.active}/db.properties,optional:file:src/main/resources/${spring.profiles.active}/db.properties
|
||||
app.webgl.asset-origin=
|
||||
app.webgl.frame-ancestors=self
|
||||
|
||||
# log
|
||||
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ spring.datasource.username=your_username
|
|||
spring.datasource.password=your_password
|
||||
|
||||
app.upload.game-storage-path=src/main/resources/static/game
|
||||
app.webgl.asset-origin=
|
||||
app.webgl.frame-ancestors=self
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ spring.datasource.username=your_username
|
|||
spring.datasource.password=your_password
|
||||
|
||||
app.upload.game-storage-path=src/main/resources/static/game
|
||||
app.webgl.asset-origin=
|
||||
app.webgl.frame-ancestors=self
|
||||
|
|
|
|||
|
|
@ -1,6 +1,24 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<%
|
||||
boolean owner = Boolean.TRUE.equals(request.getAttribute("owner"));
|
||||
String ctx = request.getContextPath();
|
||||
String gameIdValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameId")));
|
||||
String gameNameValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gameName")));
|
||||
String creatorValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creator")));
|
||||
String likeCountFormattedValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("likeCountFormatted")));
|
||||
String creatorNoteValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("creatorNote")));
|
||||
String gitUrlValue = HtmlUtils.htmlEscape(String.valueOf(request.getAttribute("gitUrl")));
|
||||
String webglFrameSrc = String.valueOf(request.getAttribute("webglFrameSrc"));
|
||||
if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {
|
||||
webglFrameSrc = String.valueOf(request.getAttribute("webglUrl"));
|
||||
}
|
||||
if (webglFrameSrc == null || webglFrameSrc.isBlank() || "null".equals(webglFrameSrc)) {
|
||||
webglFrameSrc = "";
|
||||
} else if (webglFrameSrc.startsWith("/")) {
|
||||
webglFrameSrc = ctx + webglFrameSrc;
|
||||
}
|
||||
String webglFrameSrcValue = HtmlUtils.htmlEscape(webglFrameSrc);
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -8,7 +26,7 @@
|
|||
<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>${gameName} — bibimbap</title>
|
||||
<title><%= gameNameValue %> — bibimbap</title>
|
||||
<style>
|
||||
html {
|
||||
color-scheme: light;
|
||||
|
|
@ -669,20 +687,20 @@
|
|||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||
<main class="game-page">
|
||||
<div class="game-topbar">
|
||||
<a class="game-back" href="${pageContext.request.contextPath}/">
|
||||
<a class="game-back" href="<%= ctx %>/">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
목록으로
|
||||
</a>
|
||||
<% if (owner) { %>
|
||||
<div class="game-owner-actions" aria-label="게시물 관리">
|
||||
<a class="game-owner-button" href="${pageContext.request.contextPath}/game/${gameId}/edit">수정</a>
|
||||
<a class="game-owner-button" href="<%= ctx %>/game/<%= gameIdValue %>/edit">수정</a>
|
||||
<button type="button" class="game-owner-button game-owner-button--danger" id="game-delete-button">삭제</button>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<section class="game-info-card" aria-labelledby="game-title">
|
||||
<h1 id="game-title">${gameName}</h1>
|
||||
<h1 id="game-title"><%= gameNameValue %></h1>
|
||||
<div class="game-meta-grid">
|
||||
<div class="game-meta-item">
|
||||
<div class="game-meta-item__row">
|
||||
|
|
@ -691,7 +709,7 @@
|
|||
</span>
|
||||
<span class="game-meta-item__label">번호</span>
|
||||
</div>
|
||||
<span class="game-meta-item__value">#${gameId}</span>
|
||||
<span class="game-meta-item__value">#<%= gameIdValue %></span>
|
||||
</div>
|
||||
<div class="game-meta-item">
|
||||
<div class="game-meta-item__row">
|
||||
|
|
@ -700,7 +718,7 @@
|
|||
</span>
|
||||
<span class="game-meta-item__label">제작자</span>
|
||||
</div>
|
||||
<span class="game-meta-item__value">${creator}</span>
|
||||
<span class="game-meta-item__value"><%= creatorValue %></span>
|
||||
</div>
|
||||
<div class="game-meta-item game-meta-item--likes">
|
||||
<div class="game-meta-item__row">
|
||||
|
|
@ -713,7 +731,7 @@
|
|||
<svg class="game-meta-like-btn__heart" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
|
||||
</svg>
|
||||
<span id="game-like-count" class="game-meta-like-btn__count">${likeCountFormatted}</span>
|
||||
<span id="game-like-count" class="game-meta-like-btn__count"><%= likeCountFormattedValue %></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -729,8 +747,9 @@
|
|||
</div>
|
||||
<div class="game-webgl__shell">
|
||||
<div class="game-webgl__frame-wrap">
|
||||
<iframe title="${gameName} WebGL"
|
||||
src="${pageContext.request.contextPath}${webglUrl}"
|
||||
<iframe title="<%= gameNameValue %> WebGL"
|
||||
src="<%= webglFrameSrcValue %>"
|
||||
sandbox="allow-scripts allow-same-origin allow-pointer-lock"
|
||||
allow="fullscreen; autoplay; xr-spatial-tracking"
|
||||
loading="eager"
|
||||
referrerpolicy="same-origin"></iframe>
|
||||
|
|
@ -749,9 +768,9 @@
|
|||
<p class="game-panel__subtitle">이 게임을 만들며 전하고 싶었던 이야기예요.</p>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote class="game-panel__quote">${creatorNote}</blockquote>
|
||||
<blockquote class="game-panel__quote"><%= creatorNoteValue %></blockquote>
|
||||
<div class="game-panel__git-wrap">
|
||||
<a class="game-panel__git" href="${gitUrl}" target="_blank" rel="noopener noreferrer">
|
||||
<a class="game-panel__git" href="<%= gitUrlValue %>" target="_blank" rel="noopener noreferrer">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
소스 코드 · GitHub
|
||||
</a>
|
||||
|
|
@ -828,7 +847,10 @@
|
|||
function deleteGame() {
|
||||
fetch(ctx + '/game/' + encodeURIComponent(gid), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -520,7 +520,10 @@
|
|||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
|
|
@ -555,7 +558,11 @@
|
|||
|
||||
return fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<jsp:include page="/WEB-INF/views/theme-init.jsp"/>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<%
|
||||
jakarta.servlet.http.HttpSession headerSession = request.getSession(false);
|
||||
boolean headerLoggedIn = headerSession != null && headerSession.getAttribute("userId") != null;
|
||||
String headerProfileHref = request.getContextPath() + (headerLoggedIn ? "/profile" : "/login");
|
||||
String headerProfileLabel = headerLoggedIn ? "프로필" : "로그인";
|
||||
String headerAvatarUrl = headerSession != null && headerSession.getAttribute("avatarUrl") != null
|
||||
String rawHeaderAvatarUrl = headerSession != null && headerSession.getAttribute("avatarUrl") != null
|
||||
? String.valueOf(headerSession.getAttribute("avatarUrl"))
|
||||
: "";
|
||||
boolean headerHasAvatar = headerLoggedIn && !headerAvatarUrl.isBlank();
|
||||
String headerAvatarUrl = HtmlUtils.htmlEscape(rawHeaderAvatarUrl);
|
||||
boolean headerHasAvatar = headerLoggedIn && !rawHeaderAvatarUrl.isBlank();
|
||||
%>
|
||||
<style>
|
||||
/* 기본(라이트) 헤더 — data-theme 없을 때도 동일 톤 */
|
||||
|
|
|
|||
|
|
@ -596,7 +596,11 @@
|
|||
|
||||
fetch(ctx + '/profile/nickname', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
|
|
@ -657,7 +661,10 @@
|
|||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -383,7 +383,11 @@
|
|||
var body = new URLSearchParams(new FormData(form));
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
|
||||
<%@ page import="com.pandoli365.bibimbap.security.CsrfTokens" %>
|
||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||
<%
|
||||
String themeCsrfToken = CsrfTokens.getOrCreate(request.getSession());
|
||||
%>
|
||||
<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(themeCsrfToken) %>">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
|
|
@ -9,5 +15,18 @@
|
|||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (e) {}
|
||||
|
||||
window.BibimbapCsrf = {
|
||||
token: function () {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute('content') || '' : '';
|
||||
},
|
||||
headers: function (extra) {
|
||||
var headers = extra || {};
|
||||
var token = this.token();
|
||||
if (token) headers['X-CSRF-Token'] = token;
|
||||
return headers;
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,104 +2,54 @@
|
|||
-- update = live
|
||||
-- generated SQL only. Review before running.
|
||||
|
||||
-- ONLY IN dev: game_comments
|
||||
-- ONLY IN dev: game_likes
|
||||
-- ONLY IN dev: games
|
||||
-- ONLY IN dev: user_auth_identities
|
||||
-- ONLY IN dev: users
|
||||
-- ONLY IN dev: recruit_posts
|
||||
|
||||
-- game_comments table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_comments_id_seq";
|
||||
CREATE TABLE "game_comments" (
|
||||
"id" bigint DEFAULT nextval('game_comments_id_seq'::regclass) NOT NULL,
|
||||
"game_id" bigint NOT NULL,
|
||||
"nickname" character varying(50) NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- game_likes table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_likes_id_seq";
|
||||
CREATE TABLE "game_likes" (
|
||||
"id" bigint DEFAULT nextval('game_likes_id_seq'::regclass) NOT NULL,
|
||||
"game_id" bigint NOT NULL,
|
||||
"user_key" character varying(100) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- games table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "games_id_seq";
|
||||
CREATE TABLE "games" (
|
||||
"id" bigint DEFAULT nextval('games_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(200) NOT NULL,
|
||||
"creator_note" text,
|
||||
"git_url" character varying(500),
|
||||
"webgl_path" character varying(500),
|
||||
"thumbnail_url" character varying(500),
|
||||
"like_count" integer DEFAULT 0 NOT NULL,
|
||||
-- DATA DIFF games: row differs where id=4 columns name, creator_note, git_url, webgl_path, thumbnail_url, sort_order, created_at, updated_at, user_id
|
||||
-- recruit_posts table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "recruit_posts_id_seq";
|
||||
CREATE TABLE "recruit_posts" (
|
||||
"id" bigint DEFAULT nextval('recruit_posts_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"project_name" character varying(200) NOT NULL,
|
||||
"genre" character varying(80),
|
||||
"summary" character varying(200) NOT NULL,
|
||||
"role" character varying(30) NOT NULL,
|
||||
"project_status" character varying(50) NOT NULL,
|
||||
"participation_type" character varying(30) NOT NULL,
|
||||
"expected_period" character varying(80),
|
||||
"team_members" character varying(200),
|
||||
"contact" character varying(200) NOT NULL,
|
||||
"description" text,
|
||||
"reference_url" character varying(500),
|
||||
"deadline_at" timestamp with time zone,
|
||||
"is_visible" boolean DEFAULT true NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- user_auth_identities table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "user_auth_identities_id_seq";
|
||||
CREATE TABLE "user_auth_identities" (
|
||||
"id" bigint DEFAULT nextval('user_auth_identities_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"provider" character varying(30) NOT NULL,
|
||||
"provider_user_id" character varying(255) NOT NULL,
|
||||
"email" character varying(255),
|
||||
"display_name" character varying(80),
|
||||
"avatar_url" character varying(500),
|
||||
"last_login_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
"password_hash" character varying(255),
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
COMMENT ON COLUMN "user_auth_identities"."id" IS '로그인 연결 정보 고유 ID';
|
||||
COMMENT ON COLUMN "user_auth_identities"."user_id" IS '연결된 서비스 내부 사용자 ID';
|
||||
COMMENT ON COLUMN "user_auth_identities"."provider" IS '로그인 제공자. guest, google, email, kakao, naver, github, apple';
|
||||
COMMENT ON COLUMN "user_auth_identities"."provider_user_id" IS '로그인 제공자가 내려준 사용자 고유 ID. 게스트는 자체 생성 ID 사용';
|
||||
COMMENT ON COLUMN "user_auth_identities"."email" IS '해당 로그인 제공자에서 받은 이메일 주소';
|
||||
COMMENT ON COLUMN "user_auth_identities"."display_name" IS '해당 로그인 제공자에서 받은 표시 이름';
|
||||
COMMENT ON COLUMN "user_auth_identities"."avatar_url" IS '해당 로그인 제공자에서 받은 프로필 이미지 URL';
|
||||
COMMENT ON COLUMN "user_auth_identities"."last_login_at" IS '해당 로그인 방식으로 마지막 로그인한 시각';
|
||||
COMMENT ON COLUMN "user_auth_identities"."created_at" IS '로그인 연결 정보 생성 시각';
|
||||
COMMENT ON COLUMN "user_auth_identities"."updated_at" IS '로그인 연결 정보 마지막 수정 시각';
|
||||
|
||||
-- users table does not exist.
|
||||
CREATE SEQUENCE IF NOT EXISTS "users_id_seq";
|
||||
CREATE TABLE "users" (
|
||||
"id" bigint DEFAULT nextval('users_id_seq'::regclass) NOT NULL,
|
||||
"display_name" character varying(80) NOT NULL,
|
||||
"canonical_email" character varying(255),
|
||||
"avatar_url" character varying(500),
|
||||
"role" character varying(30) DEFAULT 'USER'::character varying NOT NULL,
|
||||
"status" character varying(30) DEFAULT 'ACTIVE'::character varying NOT NULL,
|
||||
"last_login_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
COMMENT ON COLUMN "users"."id" IS '사용자 고유 ID';
|
||||
COMMENT ON COLUMN "users"."display_name" IS '서비스에서 표시할 사용자 이름';
|
||||
COMMENT ON COLUMN "users"."canonical_email" IS '대표 이메일 주소. 여러 로그인 제공자 중 기준 이메일로 사용';
|
||||
COMMENT ON COLUMN "users"."avatar_url" IS '사용자 프로필 이미지 URL';
|
||||
COMMENT ON COLUMN "users"."role" IS '사용자 권한. USER 또는 ADMIN';
|
||||
COMMENT ON COLUMN "users"."status" IS '계정 상태. ACTIVE, SUSPENDED, DELETED';
|
||||
COMMENT ON COLUMN "users"."last_login_at" IS '마지막 로그인 시각';
|
||||
COMMENT ON COLUMN "users"."created_at" IS '사용자 생성 시각';
|
||||
COMMENT ON COLUMN "users"."updated_at" IS '사용자 정보 마지막 수정 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."id" IS '모집글 고유 ID';
|
||||
COMMENT ON COLUMN "recruit_posts"."user_id" IS '모집글 작성자 users.id';
|
||||
COMMENT ON COLUMN "recruit_posts"."project_name" IS '프로젝트 이름';
|
||||
COMMENT ON COLUMN "recruit_posts"."genre" IS '게임 장르';
|
||||
COMMENT ON COLUMN "recruit_posts"."summary" IS '한 줄 소개';
|
||||
COMMENT ON COLUMN "recruit_posts"."role" IS '모집 역할. 기획, 아트, 프로그래머';
|
||||
COMMENT ON COLUMN "recruit_posts"."project_status" IS '프로젝트 진행 상태';
|
||||
COMMENT ON COLUMN "recruit_posts"."participation_type" IS '참여 방식. 취미, 수익쉐어, 유급, 게임잼';
|
||||
COMMENT ON COLUMN "recruit_posts"."expected_period" IS '예상 작업 기간';
|
||||
COMMENT ON COLUMN "recruit_posts"."team_members" IS '현재 팀 구성';
|
||||
COMMENT ON COLUMN "recruit_posts"."contact" IS '연락 방법';
|
||||
COMMENT ON COLUMN "recruit_posts"."description" IS '상세 설명';
|
||||
COMMENT ON COLUMN "recruit_posts"."reference_url" IS '참고 링크';
|
||||
COMMENT ON COLUMN "recruit_posts"."deadline_at" IS '모집 마감 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."is_visible" IS '목록 공개 여부';
|
||||
COMMENT ON COLUMN "recruit_posts"."sort_order" IS '정렬 우선순위';
|
||||
COMMENT ON COLUMN "recruit_posts"."created_at" IS '모집글 생성 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."updated_at" IS '모집글 마지막 수정 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."deleted_at" IS '모집글 삭제 시각';
|
||||
COMMENT ON COLUMN "recruit_posts"."is_delete" IS '소프트 삭제 여부';
|
||||
|
||||
-- DATA DIFF user_auth_identities: row differs where id=5 columns user_id, provider_user_id, email, display_name, avatar_url, last_login_at, created_at, updated_at, password_hash
|
||||
-- DATA DIFF users: row exists only in dev where id=8
|
||||
|
|
|
|||
Loading…
Reference in New Issue