diff --git a/build.properties.example b/build.properties.example index 342318b..7169b4d 100644 --- a/build.properties.example +++ b/build.properties.example @@ -10,6 +10,10 @@ pyx.insecure_id_allowed=true pyx.id_code_salt= # comma-separated listed of IP addresses (v4 or v6) from which users are considered admins. pyx.admin_addrs=127.0.0.1,::1 +# this many messages in that many seconds is considered chatting too fast. +pyx.flood_count=4 +# seconds +pyx.flood_time=30 # for production use, use postgres #hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/filtered-resources/WEB-INF/pyx.properties b/src/main/filtered-resources/WEB-INF/pyx.properties index 2ddf662..ad71923 100644 --- a/src/main/filtered-resources/WEB-INF/pyx.properties +++ b/src/main/filtered-resources/WEB-INF/pyx.properties @@ -6,6 +6,8 @@ pyx.server.broadcast_connects_and_disconnects=${pyx.broadcast_connects_and_disco pyx.server.global_chat_enabled=${pyx.global_chat_enabled} pyx.server.id_code_salt=${pyx.id_code_salt} pyx.server.admin_addrs=${pyx.admin_addrs} +pyx.chat.flood_count=${pyx.flood_count} +pyx.chat.flood_time=${pyx.flood_time} pyx.build=${buildNumber} # this is NOT allowed to be changed during a reload, as metrics depend on previous events diff --git a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java index 477208d..8373eee 100644 --- a/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/ChatHandler.java @@ -28,10 +28,11 @@ import java.util.Map; import javax.servlet.http.HttpSession; +import org.apache.log4j.Logger; + import com.google.inject.Inject; import net.socialgamer.cah.CahModule.GlobalChatEnabled; -import net.socialgamer.cah.Constants; import net.socialgamer.cah.Constants.AjaxOperation; import net.socialgamer.cah.Constants.AjaxRequest; import net.socialgamer.cah.Constants.ErrorCode; @@ -43,6 +44,7 @@ import net.socialgamer.cah.RequestWrapper; import net.socialgamer.cah.data.ConnectedUsers; import net.socialgamer.cah.data.QueuedMessage.MessageType; import net.socialgamer.cah.data.User; +import net.socialgamer.cah.util.ChatFilter; /** @@ -52,16 +54,19 @@ import net.socialgamer.cah.data.User; */ public class ChatHandler extends Handler { + private static final Logger LOG = Logger.getLogger(ChatHandler.class); public static final String OP = AjaxOperation.CHAT.toString(); + private final ChatFilter chatFilter; private final ConnectedUsers users; private final boolean globalChatEnabled; @Inject public ChatHandler(final ConnectedUsers users, - @GlobalChatEnabled final boolean globalChatEnabled) { + @GlobalChatEnabled final boolean globalChatEnabled, final ChatFilter chatFilter) { this.users = users; this.globalChatEnabled = globalChatEnabled; + this.chatFilter = chatFilter; } @Override @@ -86,39 +91,37 @@ public class ChatHandler extends Handler { } else { final String message = request.getParameter(AjaxRequest.MESSAGE).trim(); - // Intentionally leaving flood protection as per-user, rather than - // changing it to per-user-per-game. - if (user.getLastMessageTimes().size() >= Constants.CHAT_FLOOD_MESSAGE_COUNT) { - final Long head = user.getLastMessageTimes().get(0); - if (System.currentTimeMillis() - head < Constants.CHAT_FLOOD_TIME) { + final ChatFilter.Result filterResult = chatFilter.filterGlobal(user, message); + switch (filterResult) { + case OK: + // nothing to do + break; + case TOO_FAST: return error(ErrorCode.TOO_FAST); - } - user.getLastMessageTimes().remove(0); + case TOO_LONG: + return error(ErrorCode.MESSAGE_TOO_LONG); + case NO_MESSAGE: + return error(ErrorCode.NO_MSG_SPECIFIED); + default: + LOG.error(String.format("Unknown chat filter result %s", filterResult)); } - if (message.length() > Constants.CHAT_MAX_LENGTH) { - return error(ErrorCode.MESSAGE_TOO_LONG); - } else if (message.length() == 0) { - return error(ErrorCode.NO_MSG_SPECIFIED); - } else { - user.getLastMessageTimes().add(System.currentTimeMillis()); - final HashMap broadcastData = new HashMap(); - broadcastData.put(LongPollResponse.EVENT, LongPollEvent.CHAT.toString()); - broadcastData.put(LongPollResponse.FROM, user.getNickname()); - broadcastData.put(LongPollResponse.MESSAGE, message); - broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); - broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); - if (user.isAdmin()) { - broadcastData.put(LongPollResponse.FROM_ADMIN, true); - } - if (wall) { - broadcastData.put(LongPollResponse.WALL, true); - } - if (emote) { - broadcastData.put(LongPollResponse.EMOTE, true); - } - users.broadcastToAll(MessageType.CHAT, broadcastData); + final HashMap broadcastData = new HashMap(); + broadcastData.put(LongPollResponse.EVENT, LongPollEvent.CHAT.toString()); + broadcastData.put(LongPollResponse.FROM, user.getNickname()); + broadcastData.put(LongPollResponse.MESSAGE, message); + broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); + broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); + if (user.isAdmin()) { + broadcastData.put(LongPollResponse.FROM_ADMIN, true); } + if (wall) { + broadcastData.put(LongPollResponse.WALL, true); + } + if (emote) { + broadcastData.put(LongPollResponse.EMOTE, true); + } + users.broadcastToAll(MessageType.CHAT, broadcastData); } return data; diff --git a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java index 6d80221..800e2c0 100644 --- a/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java +++ b/src/main/java/net/socialgamer/cah/handlers/GameChatHandler.java @@ -28,9 +28,10 @@ import java.util.Map; import javax.servlet.http.HttpSession; +import org.apache.log4j.Logger; + import com.google.inject.Inject; -import net.socialgamer.cah.Constants; import net.socialgamer.cah.Constants.AjaxOperation; import net.socialgamer.cah.Constants.AjaxRequest; import net.socialgamer.cah.Constants.ErrorCode; @@ -42,6 +43,7 @@ import net.socialgamer.cah.data.Game; import net.socialgamer.cah.data.GameManager; import net.socialgamer.cah.data.QueuedMessage.MessageType; import net.socialgamer.cah.data.User; +import net.socialgamer.cah.util.ChatFilter; /** @@ -51,11 +53,15 @@ import net.socialgamer.cah.data.User; */ public class GameChatHandler extends GameWithPlayerHandler { + private static final Logger LOG = Logger.getLogger(GameChatHandler.class); public static final String OP = AjaxOperation.GAME_CHAT.toString(); + private final ChatFilter chatFilter; + @Inject - public GameChatHandler(final GameManager gameManager) { + public GameChatHandler(final GameManager gameManager, final ChatFilter chatFilter) { super(gameManager); + this.chatFilter = chatFilter; } @Override @@ -70,33 +76,31 @@ public class GameChatHandler extends GameWithPlayerHandler { } else { final String message = request.getParameter(AjaxRequest.MESSAGE).trim(); - // Intentionally leaving flood protection as per-user, rather than - // changing it to per-user-per-game. - if (user.getLastMessageTimes().size() >= Constants.CHAT_FLOOD_MESSAGE_COUNT) { - final Long head = user.getLastMessageTimes().get(0); - if (System.currentTimeMillis() - head < Constants.CHAT_FLOOD_TIME) { + final ChatFilter.Result filterResult = chatFilter.filterGame(user, message); + switch (filterResult) { + case OK: + // nothing to do + break; + case TOO_FAST: return error(ErrorCode.TOO_FAST); - } - user.getLastMessageTimes().remove(0); + case TOO_LONG: + return error(ErrorCode.MESSAGE_TOO_LONG); + case NO_MESSAGE: + return error(ErrorCode.NO_MSG_SPECIFIED); + default: + LOG.error(String.format("Unknown chat filter result %s", filterResult)); } - if (message.length() > Constants.CHAT_MAX_LENGTH) { - return error(ErrorCode.MESSAGE_TOO_LONG); - } else if (message.length() == 0) { - return error(ErrorCode.NO_MSG_SPECIFIED); - } else { - user.getLastMessageTimes().add(System.currentTimeMillis()); - final HashMap broadcastData = new HashMap(); - broadcastData.put(LongPollResponse.EVENT, LongPollEvent.CHAT.toString()); - broadcastData.put(LongPollResponse.FROM, user.getNickname()); - broadcastData.put(LongPollResponse.MESSAGE, message); - broadcastData.put(LongPollResponse.FROM_ADMIN, user.isAdmin()); - broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); - broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); - broadcastData.put(LongPollResponse.GAME_ID, game.getId()); - broadcastData.put(LongPollResponse.EMOTE, emote); - game.broadcastToPlayers(MessageType.CHAT, broadcastData); - } + final HashMap broadcastData = new HashMap(); + broadcastData.put(LongPollResponse.EVENT, LongPollEvent.CHAT.toString()); + broadcastData.put(LongPollResponse.FROM, user.getNickname()); + broadcastData.put(LongPollResponse.MESSAGE, message); + broadcastData.put(LongPollResponse.FROM_ADMIN, user.isAdmin()); + broadcastData.put(LongPollResponse.ID_CODE, user.getIdCode()); + broadcastData.put(LongPollResponse.SIGIL, user.getSigil().toString()); + broadcastData.put(LongPollResponse.GAME_ID, game.getId()); + broadcastData.put(LongPollResponse.EMOTE, emote); + game.broadcastToPlayers(MessageType.CHAT, broadcastData); } return data; diff --git a/src/main/java/net/socialgamer/cah/util/ChatFilter.java b/src/main/java/net/socialgamer/cah/util/ChatFilter.java new file mode 100644 index 0000000..436fd45 --- /dev/null +++ b/src/main/java/net/socialgamer/cah/util/ChatFilter.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2018, Andy Janata + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted + * provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this list of conditions + * and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of + * conditions and the following disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package net.socialgamer.cah.util; + +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import org.apache.log4j.Logger; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; + +import net.socialgamer.cah.Constants; +import net.socialgamer.cah.data.User; + + +/** + * Filter for chat messages. Currently handles flood limiting, can be extended with a lot more. + */ +@Singleton +public class ChatFilter { + private static final Logger LOG = Logger.getLogger(ChatFilter.class); + + private static final int DEFAULT_CHAT_FLOOD_MESSAGE_COUNT = 4; + private static final long DEFAULT_CHAT_FLOOD_TIME = TimeUnit.SECONDS.toMillis(30); + + private final Provider propsProvider; + + public enum Result { + OK, TOO_FAST, TOO_LONG, NO_MESSAGE + } + + @Inject + public ChatFilter(final Provider propsProvider) { + this.propsProvider = propsProvider; + } + + public Result filterGlobal(final User user, final String message) { + final Result result = filterCommon(user, message); + if (Result.OK != result) { + return result; + } + + // TODO + user.getLastMessageTimes().add(System.currentTimeMillis()); + return result; + } + + public Result filterGame(final User user, final String message) { + final Result result = filterCommon(user, message); + if (Result.OK != result) { + return result; + } + + // TODO + user.getLastMessageTimes().add(System.currentTimeMillis()); + return result; + } + + private Result filterCommon(final User user, final String message) { + // TODO + + // Intentionally leaving flood protection as per-user, rather than + // changing it to per-user-per-game. + if (user.getLastMessageTimes().size() >= getFloodCount()) { + final Long head = user.getLastMessageTimes().get(0); + if (System.currentTimeMillis() - head < getFloodTime()) { + return Result.TOO_FAST; + } + user.getLastMessageTimes().remove(0); + } + + if (message.length() > Constants.CHAT_MAX_LENGTH) { + return Result.TOO_LONG; + } else if (message.length() == 0) { + return Result.NO_MESSAGE; + } + + return Result.OK; + } + + private int getFloodCount() { + try { + return Integer.parseInt(propsProvider.get().getProperty("pyx.chat.flood_count", + String.valueOf(DEFAULT_CHAT_FLOOD_MESSAGE_COUNT))); + } catch (final NumberFormatException e) { + LOG.warn(String.format("Unable to parse pyx.chat.flood_count as a number," + + " using default of %d", DEFAULT_CHAT_FLOOD_MESSAGE_COUNT), e); + return DEFAULT_CHAT_FLOOD_MESSAGE_COUNT; + } + } + + private long getFloodTime() { + try { + return TimeUnit.SECONDS.toMillis(Integer.parseInt(propsProvider.get().getProperty( + "pyx.chat.flood_time", String.valueOf(DEFAULT_CHAT_FLOOD_TIME)))); + } catch (final NumberFormatException e) { + LOG.warn(String.format("Unable to parse pyx.chat.flood_time as a number," + + " using default of %d", DEFAULT_CHAT_FLOOD_TIME), e); + return DEFAULT_CHAT_FLOOD_TIME; + } + } +}