페이지 개선

This commit is contained in:
pandoli365 2026-06-13 15:05:12 +09:00
parent 2cca6c72a6
commit d88cfbf5fb
16 changed files with 262 additions and 128 deletions

View File

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

View File

@ -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, "로그인이 필요합니다.");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 없을 때도 동일 톤 */

View File

@ -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'
},

View File

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

View File

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

View File

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