Skip to content
Merged
245 changes: 194 additions & 51 deletions src/main/java/game/multi/ws/MultiWebSocket.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package game.multi.ws;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.HttpSession;
import javax.websocket.EndpointConfig;
Expand All @@ -11,6 +13,9 @@
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

import com.google.gson.Gson;
import com.google.gson.JsonObject;

import config.WebSocketConfig;
import game.multi.dao.MultiPlayerDAO;
import game.multi.dao.MultiPlayerDAOImpl;
Expand All @@ -21,58 +26,147 @@
public class MultiWebSocket {

private static final MultiGameService service = new MultiGameService();

private static final MultiPlayerDAO multiPlayerDao = new MultiPlayerDAOImpl();

/*
* room 단위 세션/유저/닉/슬롯 캐시
* - 이모지 브로드캐스트를 해당 room에만 하기 위함
* - GAME_MULTI_START 메시지를 dispatch에서 감지해 slot을 캐싱함
*/
private static final Map<String, String> sessionRoomMap = new ConcurrentHashMap<>(); // sessionId -> roomId
private static final Map<String, String> sessionUserMap = new ConcurrentHashMap<>(); // sessionId -> userId
private static final Map<String, String> sessionNickMap = new ConcurrentHashMap<>(); // sessionId -> nickname
private static final Map<String, Integer> sessionSlotMap = new ConcurrentHashMap<>(); // sessionId -> slot(0~3)

private static final Map<String, Map<String, Session>> roomSessions = new ConcurrentHashMap<>(); // roomId -> (sessionId -> Session)

private static final Gson gson = new Gson();

@OnOpen
public void onOpen(Session session, EndpointConfig config) {
try {
// HttpSession에서 userId 가져오기
HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
String userId = null;
if (httpSession != null) {
userId = (String) httpSession.getAttribute("loginUserId");
}

// URL 쿼리 스트링에서 roomId 파싱
String query = session.getRequestURI().getQuery();
String roomId = getParameterValue(query, "roomId");

if (roomId == null || roomId.trim().isEmpty()) {
// 방 ID가 없으면 에러 처리 혹은 기본방("default")
roomId = "default";
}

// DB로 방 멤버 검증
if (!multiPlayerDao.isMember(roomId, userId)) {
session.close();
return;
}

// Service에 roomId와 userId 함께 전달
List<SendJob> jobs = service.handleOpen(session, roomId, userId);
dispatch(session, jobs);

} catch (Exception e) {
e.printStackTrace();
}
}

public void onOpen(Session session, EndpointConfig config) {
try {
// HttpSession에서 userId 가져오기
HttpSession httpSession = (HttpSession)config.getUserProperties().get(HttpSession.class.getName());
String userId = null;
if (httpSession != null) {
userId = (String)httpSession.getAttribute("loginUserId");
}

// URL 쿼리 스트링에서 roomId 파싱
String query = session.getRequestURI().getQuery();
String roomId = getParameterValue(query, "roomId");

if (roomId == null || roomId.trim().isEmpty()) {
roomId = "default"; // 방 ID가 없을 때
}

// DB로 방 멤버 검증
if (!multiPlayerDao.isMember(roomId, userId)) {
session.close();
return;
}

/*
* room/session 캐시 등록
* SingleWebSocket과 동일 -> loginNickname 사용
*/
sessionRoomMap.put(session.getId(), roomId);
if (userId != null)
sessionUserMap.put(session.getId(), userId);

String nickname = null;
if (httpSession != null) {
nickname = (String)httpSession.getAttribute("loginNickname");
}
if (nickname == null || nickname.isBlank()) {
nickname = (userId != null ? userId : "unknown");
}
sessionNickMap.put(session.getId(), nickname);

roomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>())
.put(session.getId(), session);

// Service에 roomId와 userId 함께 전달
List<SendJob> jobs = service.handleOpen(session, roomId, userId);
dispatch(session, jobs);

} catch (Exception e) {
e.printStackTrace();
}
}

// 쿼리 스트링 파싱 헬퍼 메서드
private String getParameterValue(String query, String name) {
if (query == null) return null;
for (String param : query.split("&")) {
String[] pair = param.split("=");
if (pair.length > 1 && pair[0].equals(name)) {
return pair[1];
}
}
return null;
}
private String getParameterValue(String query, String name) {
if (query == null)
return null;
for (String param : query.split("&")) {
String[] pair = param.split("=");
if (pair.length > 1 && pair[0].equals(name)) {
return pair[1];
}
}
return null;
}

@OnMessage
public void onMessage(String msg, Session session) {
try {
/*
* 이모지 채팅: "EMOJI_CHAT:smile" 텍스트 프로토콜
* - 같은 방에 브로드캐스트
* - SingleWebSocket과 동일한 JSON 포맷(type/payload) + slot 추가
*/
if (msg != null && msg.startsWith("EMOJI_CHAT:")) {
String emojiKey = msg.substring("EMOJI_CHAT:".length()).trim();
if (emojiKey.isEmpty())
return;

String roomId = sessionRoomMap.get(session.getId());
if (roomId == null)
return;

String userId = sessionUserMap.get(session.getId());
String nickname = sessionNickMap.get(session.getId());
if (nickname == null || nickname.isBlank())
nickname = (userId != null ? userId : "unknown");

int slot = -1;
Integer cachedSlot = sessionSlotMap.get(session.getId());
if (cachedSlot != null)
slot = cachedSlot;

JsonObject root = new JsonObject();
root.addProperty("type", "EMOJI_CHAT");

JsonObject payload = new JsonObject();
if (userId != null)
payload.addProperty("from", userId);
payload.addProperty("fromNick", nickname);
payload.addProperty("emoji", emojiKey);
if (slot >= 0)
payload.addProperty("slot", slot);

root.add("payload", payload);

String json = gson.toJson(root);

// room 내 세션에게만 전송
Map<String, Session> targets = roomSessions.get(roomId);
if (targets != null) {
for (Session s : targets.values()) {
if (s != null && s.isOpen()) {
try {
synchronized (s) {
s.getBasicRemote().sendText(json);
}
} catch (Exception ignore) {}
}
}
}
return;
}
Comment on lines +120 to +168
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

입력 검증 부재 및 예외 처리 개선 필요

emojiKey에 대한 검증이 없습니다. 현재는 클라이언트가 전달한 값을 그대로 JSON에 포함하여 브로드캐스트합니다. 악의적인 입력(예: 매우 긴 문자열, 특수 문자)에 대한 방어가 필요합니다.

또한 Line 163에서 예외를 무시하면 디버깅이 어려워집니다.

🔎 개선 제안
 if (msg != null && msg.startsWith("EMOJI_CHAT:")) {
     String emojiKey = msg.substring("EMOJI_CHAT:".length()).trim();
-    if (emojiKey.isEmpty())
+    // 허용된 이모지 키만 처리
+    if (emojiKey.isEmpty() || !isValidEmojiKey(emojiKey))
         return;
// 헬퍼 메서드 추가
private boolean isValidEmojiKey(String key) {
    return key != null && key.matches("^(smile|angry|clap)$");
}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/game/multi/ws/MultiWebSocket.java around lines 120-168,
validate and sanitize the emojiKey before using it and stop swallowing
exceptions: add a helper (e.g., isValidEmojiKey) that enforces non-null, max
length (e.g., 64 chars) and an allowlist or safe pattern (e.g., alphanumerics,
hyphens/underscores or explicit emoji keys), call it after extracting emojiKey
and return if it fails (optionally send an error to the sender); when
broadcasting, catch exceptions but log them with context (session id, roomId,
target session id) instead of ignoring so failures are debuggable. Ensure the
payload only contains the validated/sanitized emojiKey and keep existing
slot/nickname handling.


List<SendJob> jobs = service.handleMessage(session, msg);
dispatch(session, jobs);
} catch (Exception e) {
Expand All @@ -83,6 +177,22 @@ public void onMessage(String msg, Session session) {
@OnClose
public void onClose(Session session) {
try {
/* room/session 캐시 정리 */
String roomId = sessionRoomMap.remove(session.getId());
sessionUserMap.remove(session.getId());
sessionNickMap.remove(session.getId());
sessionSlotMap.remove(session.getId());

if (roomId != null) {
Map<String, Session> targets = roomSessions.get(roomId);
if (targets != null) {
targets.remove(session.getId());
if (targets.isEmpty()) {
roomSessions.remove(roomId);
}
}
}

List<SendJob> jobs = service.handleClose(session);
dispatch(session, jobs);
} catch (Exception e) {
Expand All @@ -97,35 +207,68 @@ public void onError(Session session, Throwable t) {

private void dispatch(Session fallback, List<SendJob> jobs) {
// 전체에게 보내야 할 경우를 대비해 현재 열려있는 모든 세션을 가져옴
// Service가 target=null을 주면 "모두에게" 보냄
// Service가 target=null을 주면 모두에게 보냄

for (SendJob job : jobs) {
try {
/*
* GAME_MULTI_START를 감지해서 slot 캐싱
* GameRoom이 보내는 {"type":"GAME_MULTI_START","slot":i,...}를 여기서 읽어둠
*/
if (job.target() != null) {
cacheSlotIfStart(job.target(), job.text());
}
// ==================================================

// 1. 전체 전송 (Broadcast)
if (job.target() == null) {
// 타겟이 없으면(null) => 브로드캐스트 (전체 전송)
for (Session s : fallback.getOpenSessions()) {
if (s.isOpen()) {
try {
// 동기화 처리로 충돌 방지
// 충돌 방지용 동기화 처리
synchronized (s) {
s.getBasicRemote().sendText(job.text());
}
} catch (Exception e) { }
} catch (Exception e) {}
}
}
}
}
// 2. 개별 전송 (Unicast)
else {
if (job.target().isOpen()) {
try {
synchronized (job.target()) {
job.target().getBasicRemote().sendText(job.text());
}
} catch (Exception e) { }
} catch (Exception e) {}
}
}
} catch (Exception ignore) { }
} catch (Exception ignore) {}
}
}
}

/* GAME_MULTI_START 메시지에서 slot을 캐싱 */
private void cacheSlotIfStart(Session target, String text) {
try {
if (text == null)
return;
// 파싱 최소화
if (!text.contains("\"type\"") || !text.contains("GAME_MULTI_START"))
return;

JsonObject obj = gson.fromJson(text, JsonObject.class);
if (obj == null || !obj.has("type"))
return;

String type = obj.get("type").getAsString();
if (!"GAME_MULTI_START".equals(type))
return;

if (obj.has("slot")) {
int slot = obj.get("slot").getAsInt();
sessionSlotMap.put(target.getId(), slot);
}
} catch (Exception ignore) {}
}
}
2 changes: 1 addition & 1 deletion src/main/webapp/WEB-INF/views/chat/gameEmoji.jsp
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%@ page contentType="text/html; charset=UTF-8" %>

<!-- 게임 중 이모지 전용 UI (fragment) -->
<!-- 게임 중 이모지 전용 UI -->
<div class="emoji-game-wrap">
<div class="player-cards">
<div id="p1" class="player-card">
Expand Down
Loading