diff --git a/src/net/socialgamer/cah/StartupUtils.java b/src/net/socialgamer/cah/StartupUtils.java index e1d26a6..fe848d7 100644 --- a/src/net/socialgamer/cah/StartupUtils.java +++ b/src/net/socialgamer/cah/StartupUtils.java @@ -33,6 +33,9 @@ import java.util.concurrent.TimeUnit; import javax.servlet.ServletContext; import javax.servlet.ServletContextEvent; +import net.socialgamer.cah.cardcast.CardcastModule; +import net.socialgamer.cah.cardcast.CardcastService; + import org.apache.log4j.PropertyConfigurator; import com.google.inject.Guice; @@ -108,6 +111,7 @@ public class StartupUtils extends GuiceServletContextListener { reconfigureLogging(contextEvent.getServletContext()); reloadProperties(contextEvent.getServletContext()); + CardcastService.hackSslVerifier(); } public static void reloadProperties(final ServletContext context) { @@ -130,6 +134,6 @@ public class StartupUtils extends GuiceServletContextListener { @Override protected Injector getInjector() { - return Guice.createInjector(new CahModule()); + return Guice.createInjector(new CahModule(), new CardcastModule()); } } diff --git a/src/net/socialgamer/cah/cardcast/CardcastBlackCard.java b/src/net/socialgamer/cah/cardcast/CardcastBlackCard.java new file mode 100644 index 0000000..67f1d21 --- /dev/null +++ b/src/net/socialgamer/cah/cardcast/CardcastBlackCard.java @@ -0,0 +1,44 @@ +package net.socialgamer.cah.cardcast; + +import net.socialgamer.cah.data.BlackCard; + + +public class CardcastBlackCard extends BlackCard { + + private final int id; + private final String text; + private final int draw; + private final int pick; + + public CardcastBlackCard(final int id, final String text, final int draw, final int pick) { + this.id = id; + this.text = text; + this.draw = draw; + this.pick = pick; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getText() { + return text; + } + + @Override + public String getWatermark() { + return "CC"; + } + + @Override + public int getDraw() { + return draw; + } + + @Override + public int getPick() { + return pick; + } +} diff --git a/src/net/socialgamer/cah/cardcast/CardcastDeck.java b/src/net/socialgamer/cah/cardcast/CardcastDeck.java new file mode 100644 index 0000000..8e4c313 --- /dev/null +++ b/src/net/socialgamer/cah/cardcast/CardcastDeck.java @@ -0,0 +1,61 @@ +package net.socialgamer.cah.cardcast; + +import java.util.HashSet; +import java.util.Set; + +import net.socialgamer.cah.data.CardSet; + + +public class CardcastDeck extends CardSet { + private final String name; + private final String code; + private final String description; + private final Set blackCards = new HashSet(); + private final Set whiteCards = new HashSet(); + + public CardcastDeck(final String name, final String code, final String description) { + this.name = name; + this.code = code; + this.description = description; + } + + @Override + public int getId() { + return -Integer.parseInt(code, 36); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public boolean isActive() { + return true; + } + + @Override + public boolean isBaseDeck() { + return false; + } + + @Override + public int getWeight() { + return Integer.MAX_VALUE; + } + + @Override + public Set getBlackCards() { + return blackCards; + } + + @Override + public Set getWhiteCards() { + return whiteCards; + } +} diff --git a/src/net/socialgamer/cah/cardcast/CardcastModule.java b/src/net/socialgamer/cah/cardcast/CardcastModule.java new file mode 100644 index 0000000..f90cd68 --- /dev/null +++ b/src/net/socialgamer/cah/cardcast/CardcastModule.java @@ -0,0 +1,33 @@ +package net.socialgamer.cah.cardcast; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.atomic.AtomicInteger; + +import net.socialgamer.cah.data.GameOptions; + +import com.google.inject.AbstractModule; +import com.google.inject.BindingAnnotation; +import com.google.inject.Provides; + + +public class CardcastModule extends AbstractModule { + + AtomicInteger cardId = new AtomicInteger(-(GameOptions.MAX_BLANK_CARD_LIMIT + 1)); + + @Override + protected void configure() { + } + + @Provides + @CardcastCardId + Integer provideCardId() { + return cardId.decrementAndGet(); + } + + @BindingAnnotation + @Retention(RetentionPolicy.RUNTIME) + public @interface CardcastCardId { + /**/ + } +} diff --git a/src/net/socialgamer/cah/cardcast/CardcastService.java b/src/net/socialgamer/cah/cardcast/CardcastService.java new file mode 100644 index 0000000..32109b6 --- /dev/null +++ b/src/net/socialgamer/cah/cardcast/CardcastService.java @@ -0,0 +1,271 @@ +package net.socialgamer.cah.cardcast; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.ref.SoftReference; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import net.socialgamer.cah.cardcast.CardcastModule.CardcastCardId; + +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; + +import com.google.inject.Inject; +import com.google.inject.Provider; + + +public class CardcastService { + private static final Logger LOG = Logger.getLogger(CardcastService.class); + + private static final String HOSTNAME = "api.cardcastgame.com"; + + /** + * Base URL to the Cardcast API. + */ + private static final String BASE_URL = "https://" + HOSTNAME + "/v1/decks/"; + /** + * URL to the Cardcast API for information about a card set. The only format replacement is the + * string deck ID. + */ + private static final String CARD_SET_INFO_URL_FORMAT_STRING = BASE_URL + "%s"; + /** + * URL to the Cardcast API for cards in a card set. The only format replacement is the string + * deck ID. + */ + private static final String CARD_SET_CARDS_URL_FORMAT_STRING = CARD_SET_INFO_URL_FORMAT_STRING + + "/cards"; + + private static final int GET_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(3); + + /** + * How long to cache nonexistent card sets, or after an error occurs while querying for the card + * set. We need to do this to prevent DoS attacks. + */ + private static final long INVALID_SET_CACHE_LIFETIME = TimeUnit.SECONDS.toMillis(30); + + /** + * How long to cache valid card sets. + */ + private static final long VALID_SET_CACHE_LIFETIME = TimeUnit.MINUTES.toMillis(15); + + private static final Map> cache = Collections + .synchronizedMap(new HashMap>()); + + private final Provider cardIdProvider; + + @Inject + public CardcastService(@CardcastCardId final Provider cardIdProvider) { + this.cardIdProvider = cardIdProvider; + } + + private class CardcastCacheEntry { + final long expires; + final CardcastDeck deck; + + CardcastCacheEntry(final long cacheLifetime, final CardcastDeck deck) { + this.expires = System.currentTimeMillis() + cacheLifetime; + this.deck = deck; + } + } + + private CardcastCacheEntry checkCache(final String setId) { + final SoftReference soft = cache.get(setId); + if (null == soft) { + return null; + } + return soft.get(); + } + + public CardcastDeck loadSet(final String setId) { + final CardcastCacheEntry cached = checkCache(setId); + if (null != cached && cached.expires < System.currentTimeMillis()) { + return cached.deck; + } + + try { + final String infoContent = getUrlContent(String + .format(CARD_SET_INFO_URL_FORMAT_STRING, setId)); + if (null == infoContent) { + // failed to load + cacheMissingSet(setId); + return null; + } + final JSONObject info = (JSONObject) JSONValue.parse(infoContent); + + final String cardContent = getUrlContent(String.format( + CARD_SET_CARDS_URL_FORMAT_STRING, setId)); + if (null == cardContent) { + // failed to load + cacheMissingSet(setId); + return null; + } + final JSONObject cards = (JSONObject) JSONValue.parse(cardContent); + + final String name = (String) info.get("name"); + final String description = (String) info.get("description"); + if (null == name || null == description || name.isEmpty()) { + // We require a name. Blank description is acceptable, but cannot be null. + cacheMissingSet(setId); + return null; + } + final CardcastDeck deck = new CardcastDeck(name, setId, description); + + // load up the cards + final JSONArray blacks = (JSONArray) cards.get("calls"); + if (null != blacks) { + for (final Object black : blacks) { + final JSONArray texts = (JSONArray) ((JSONObject) black).get("text"); + if (null != texts) { + // TODO this is going to need some work to look pretty. + final List strs = new ArrayList(texts.size()); + for (final Object o : texts) { + strs.add((String) o); + } + final String text = StringUtils.join(strs, "____"); + final int pick = strs.size() - 1; + final int draw = (pick >= 3 ? pick - 1 : 0); + final CardcastBlackCard card = new CardcastBlackCard(cardIdProvider.get(), text, draw, + pick); + deck.getBlackCards().add(card); + } + } + } + + final JSONArray whites = (JSONArray) cards.get("responses"); + if (null != whites) { + for (final Object white : whites) { + final JSONArray texts = (JSONArray) ((JSONObject) white).get("text"); + if (null != texts) { + // The white cards should only ever have one element in text, but let's be safe. + final List strs = new ArrayList(texts.size()); + for (final Object o : texts) { + strs.add((String) o); + } + final String text = StringUtils.join(strs, ""); + final CardcastWhiteCard card = new CardcastWhiteCard(cardIdProvider.get(), text); + deck.getWhiteCards().add(card); + } + } + } + + cacheSet(setId, deck); + return deck; + } catch (final Exception e) { + LOG.error(String.format("Unable to load deck %s from Cardcast", setId), e); + e.printStackTrace(); + cacheMissingSet(setId); + return null; + } + } + + private void cachePut(final String setId, final CardcastDeck deck, final long timeout) { + LOG.info(String.format("Caching %s=%s for %d ms", setId, deck, timeout)); + cache.put(setId, new SoftReference(new CardcastCacheEntry(timeout, deck))); + } + + private void cacheSet(final String setId, final CardcastDeck deck) { + cachePut(setId, deck, VALID_SET_CACHE_LIFETIME); + } + + private void cacheMissingSet(final String setId) { + cachePut(setId, null, INVALID_SET_CACHE_LIFETIME); + } + + private String getUrlContent(final String urlStr) throws IOException { + final URL url = new URL(urlStr); + final HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setDoInput(true); + conn.setDoOutput(false); + conn.setRequestMethod("GET"); + conn.setInstanceFollowRedirects(true); + conn.setReadTimeout(GET_TIMEOUT); + conn.setConnectTimeout(GET_TIMEOUT); + + final int code = conn.getResponseCode(); + if (HttpURLConnection.HTTP_OK != code) { + LOG.error(String.format("Got HTTP response code %d from Cardcast for %s", code, urlStr)); + return null; + } + final String contentType = conn.getContentType(); + if (!"application/json".equals(contentType)) { + LOG.error(String.format("Got content-type %s from Cardcast for %s", contentType, urlStr)); + return null; + } + + final InputStream is = conn.getInputStream(); + final InputStreamReader isr = new InputStreamReader(is); + final BufferedReader reader = new BufferedReader(isr); + final StringBuilder builder = new StringBuilder(4096); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + builder.append('\n'); + } + reader.close(); + isr.close(); + is.close(); + + return builder.toString(); + } + + public static void hackSslVerifier() { + // FIXME: My JVM doesn't like the certificate. I should go add StartSSL's root certificate to + // its trust store, and document steps. For now, I'm going to disable SSL certificate checking. + + // Create a trust manager that does not validate certificate chains + final TrustManager[] trustAllCerts = new TrustManager[] { + new X509TrustManager() { + @Override + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + @Override + public void checkClientTrusted(final X509Certificate[] certs, final String authType) { + } + + @Override + public void checkServerTrusted(final X509Certificate[] certs, final String authType) { + } + } + }; + + try { + final SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + } catch (final Exception e) { + LOG.error("Unable to install trust-all security manager", e); + } + + // Create host name verifier that only trusts cardcast + final HostnameVerifier allHostsValid = new HostnameVerifier() { + @Override + public boolean verify(final String hostname, final SSLSession session) { + return HOSTNAME.equals(hostname); + } + }; + + HttpsURLConnection.setDefaultHostnameVerifier(allHostsValid); + } +} diff --git a/src/net/socialgamer/cah/cardcast/CardcastWhiteCard.java b/src/net/socialgamer/cah/cardcast/CardcastWhiteCard.java new file mode 100644 index 0000000..b60bbe1 --- /dev/null +++ b/src/net/socialgamer/cah/cardcast/CardcastWhiteCard.java @@ -0,0 +1,35 @@ +package net.socialgamer.cah.cardcast; + +import net.socialgamer.cah.data.WhiteCard; + + +public class CardcastWhiteCard extends WhiteCard { + + private final int id; + private final String text; + + public CardcastWhiteCard(final int id, final String text) { + this.id = id; + this.text = text; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getText() { + return text; + } + + @Override + public String getWatermark() { + return "CC"; + } + + @Override + public boolean isWriteIn() { + return false; + } +} diff --git a/src/net/socialgamer/cah/data/Game.java b/src/net/socialgamer/cah/data/Game.java index 3c3bcbe..3157dcf 100644 --- a/src/net/socialgamer/cah/data/Game.java +++ b/src/net/socialgamer/cah/data/Game.java @@ -49,6 +49,7 @@ import net.socialgamer.cah.Constants.LongPollResponse; import net.socialgamer.cah.Constants.ReturnableData; import net.socialgamer.cah.Constants.WhiteCardData; import net.socialgamer.cah.SafeTimerTask; +import net.socialgamer.cah.cardcast.CardcastService; import net.socialgamer.cah.data.GameManager.GameId; import net.socialgamer.cah.data.QueuedMessage.MessageType; @@ -144,6 +145,7 @@ public class Game { private final Object roundTimerLock = new Object(); private volatile ScheduledFuture lastScheduledFuture; private final ScheduledThreadPoolExecutor globalTimer; + private final Provider cardcastServiceProvider; /** * Create a new game. @@ -161,12 +163,14 @@ public class Game { @Inject public Game(@GameId final Integer id, final ConnectedUsers connectedUsers, final GameManager gameManager, final ScheduledThreadPoolExecutor globalTimer, - final Provider sessionProvider) { + final Provider sessionProvider, + final Provider cardcastServiceProvider) { this.id = id; this.connectedUsers = connectedUsers; this.gameManager = gameManager; this.globalTimer = globalTimer; this.sessionProvider = sessionProvider; + this.cardcastServiceProvider = cardcastServiceProvider; state = GameState.LOBBY; } @@ -648,6 +652,9 @@ public class Game { final List cardSets = session.createQuery("from PyxCardSet where id in (:ids)") .setParameterList("ids", options.getPyxCardSetIds()).list(); + // FIXME hardcode hack for testing GBTXA + cardSets.add(cardcastServiceProvider.get().loadSet("GBTXA")); + blackDeck = new BlackDeck(cardSets); whiteDeck = new WhiteDeck(cardSets, options.blanksInDeck); } catch (final Exception e) { diff --git a/test/net/socialgamer/cah/data/GameManagerTest.java b/test/net/socialgamer/cah/data/GameManagerTest.java index b53deac..c4813f2 100644 --- a/test/net/socialgamer/cah/data/GameManagerTest.java +++ b/test/net/socialgamer/cah/data/GameManagerTest.java @@ -40,6 +40,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; import net.socialgamer.cah.HibernateUtil; +import net.socialgamer.cah.cardcast.CardcastModule.CardcastCardId; import net.socialgamer.cah.data.GameManager.GameId; import net.socialgamer.cah.data.GameManager.MaxGames; import net.socialgamer.cah.data.QueuedMessage.MessageType; @@ -114,6 +115,13 @@ public class GameManagerTest { Session provideSession() { return HibernateUtil.instance.sessionFactory.openSession(); } + + @SuppressWarnings("unused") + @Provides + @CardcastCardId + Integer provideCardcastCardId() { + return 0; + } }); gameManager = injector.getInstance(GameManager.class); @@ -135,11 +143,11 @@ public class GameManagerTest { // fill it up with 3 games assertEquals(0, gameManager.get().intValue()); - gameManager.getGames().put(0, new Game(0, cuMock, gameManager, timer, null)); + gameManager.getGames().put(0, new Game(0, cuMock, gameManager, timer, null, null)); assertEquals(1, gameManager.get().intValue()); - gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null)); + gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null)); assertEquals(2, gameManager.get().intValue()); - gameManager.getGames().put(2, new Game(2, cuMock, gameManager, timer, null)); + gameManager.getGames().put(2, new Game(2, cuMock, gameManager, timer, null, null)); // make sure it says it can't make any more assertEquals(-1, gameManager.get().intValue()); @@ -147,13 +155,13 @@ public class GameManagerTest { gameManager.destroyGame(1); // make sure it re-uses that id assertEquals(1, gameManager.get().intValue()); - gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null)); + gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null)); assertEquals(-1, gameManager.get().intValue()); // remove game 1 out from under it, to make sure it'll fix itself gameManager.getGames().remove(1); assertEquals(1, gameManager.get().intValue()); - gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null)); + gameManager.getGames().put(1, new Game(1, cuMock, gameManager, timer, null, null)); assertEquals(-1, gameManager.get().intValue()); gameManager.destroyGame(2); diff --git a/test/net/socialgamer/cah/data/GameTest.java b/test/net/socialgamer/cah/data/GameTest.java index ee41247..593fac9 100644 --- a/test/net/socialgamer/cah/data/GameTest.java +++ b/test/net/socialgamer/cah/data/GameTest.java @@ -61,7 +61,7 @@ public class GameTest { public void setUp() throws Exception { cuMock = createMock(ConnectedUsers.class); gmMock = createMock(GameManager.class); - game = new Game(0, cuMock, gmMock, timer, null); + game = new Game(0, cuMock, gmMock, timer, null, null); } @SuppressWarnings("unchecked")