PretendYoureXyzzy/src/main/java/net/socialgamer/cah/cardcast/CardcastService.java

296 lines
10 KiB
Java

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 java.util.regex.Pattern;
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 Pattern validIdPattern = Pattern.compile("[A-Z0-9]{5}");
private static final Map<String, SoftReference<CardcastCacheEntry>> cache = Collections
.synchronizedMap(new HashMap<String, SoftReference<CardcastCacheEntry>>());
private final Provider<Integer> cardIdProvider;
@Inject
public CardcastService(@CardcastCardId final Provider<Integer> 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<CardcastCacheEntry> soft = cache.get(setId);
if (null == soft) {
return null;
}
return soft.get();
}
public CardcastDeck loadSet(final String setId) {
if (!validIdPattern.matcher(setId).matches()) {
return null;
}
final CardcastCacheEntry cached = checkCache(setId);
if (null != cached && cached.expires > System.currentTimeMillis()) {
LOG.info(String.format("Using cache: %s=%s", setId, cached.deck));
return cached.deck;
} else if (null != cached) {
LOG.info(String.format("Cache stale: %s", setId));
} else {
LOG.info(String.format("Cache miss: %s", setId));
}
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<String> strs = new ArrayList<String>(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, setId);
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<String> strs = new ArrayList<String>(texts.size());
for (final Object o : texts) {
final String cardCastString = (String) o;
final StringBuilder pyxString = new StringBuilder();
// Cardcast's recommended format is to not capitalize the first letter
pyxString.append(cardCastString.substring(0, 1).toUpperCase());
pyxString.append(cardCastString.substring(1));
// Cardcast's recommended format is to not include a period
if (Character.isLetterOrDigit(cardCastString.charAt(cardCastString.length() - 1))) {
pyxString.append('.');
}
// Cardcast's white cards are now formatted consistently with pyx cards
strs.add(pyxString.toString());
}
final String text = StringUtils.join(strs, "");
final CardcastWhiteCard card = new CardcastWhiteCard(cardIdProvider.get(), text, setId);
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<CardcastCacheEntry>(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);
}
}