commit 1d78bbe27bcf70ac241566157be672dadd3525c2 Author: Michael Bayne Date: Wed Oct 5 09:37:10 2011 -0700 Extracted ooo-user bits into separate library. diff --git a/build.xml b/build.xml new file mode 100644 index 0000000..58bf807 --- /dev/null +++ b/build.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/etc/bootstrap.xml b/etc/bootstrap.xml new file mode 100644 index 0000000..b24d388 --- /dev/null +++ b/etc/bootstrap.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a595b2d --- /dev/null +++ b/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.threerings + ooo-parent + 1.0 + + + ooo-user + jar + 1.0-SNAPSHOT + ooo-user + + + + com.samskivert + depot + 1.5 + + + com.samskivert + samskivert + 1.5 + + + com.google.inject + guice + 2.0 + + + com.google.guava + guava + 10.0 + + + javax.servlet + servlet-api + 2.5 + provided + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + http://samskivert.googlecode.com/svn/apidocs/ + http://docs.guava-libraries.googlecode.com/git/javadoc/ + + + + + + diff --git a/src/main/java/com/threerings/servlet/DepotSiteIdentifier.java b/src/main/java/com/threerings/servlet/DepotSiteIdentifier.java new file mode 100644 index 0000000..9b5b522 --- /dev/null +++ b/src/main/java/com/threerings/servlet/DepotSiteIdentifier.java @@ -0,0 +1,283 @@ +// +// $Id$ + +package com.threerings.servlet; + +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import com.samskivert.depot.PersistenceContext; +import com.samskivert.servlet.Site; +import com.samskivert.servlet.SiteIdentifier; +import com.samskivert.util.ArrayUtil; + +import com.threerings.servlet.persist.DomainRecord; +import com.threerings.servlet.persist.SiteIdentifierRepository; +import com.threerings.servlet.persist.SiteRecord; + +/** + * Accomplishes the process of site identification based on a mapping from domains (e.g. + * samskivert.com) to site identifiers that is maintained in a depot database table. + * + *

There are two tables, one that maps domains to site identifiers and another that maps site + * identifiers to site strings. These are both loaded at construct time and refreshed periodically + * in the course of normal operation. + * + *

Note that any of the calls to identify, lookup or enumerate site information can result in + * the sites table being refreshed from the database which will take relatively much longer than + * the simple hashtable lookup that the operations normally require. However, this happens only + * once every 15 minutes and the circumstances in which the site identifier are normally used can + * generally accomodate the extra 100 milliseconds or so that it is likely to take to reload the + * (tiny) sites and domains tables from the database. + */ +public class DepotSiteIdentifier + implements SiteIdentifier +{ + /** + * Constructs a debot site identifier. + */ + public DepotSiteIdentifier (PersistenceContext pctx) + { + this(pctx, DEFAULT_SITE_ID); + } + + /** + * Creates an identifier that will load data from the supplied connection provider and which + * will use the supplied default site id instead of {@link #DEFAULT_SITE_ID}. + */ + public DepotSiteIdentifier (PersistenceContext pctx, int defaultSiteId) + { + this(pctx, defaultSiteId, DEFAULT_SITE_STRING); + } + + /** + * Creates an identifier that will load data from the supplied connection provider and which + * will use the supplied default site id instead of {@link #DEFAULT_SITE_ID} and the supplied + * default site string instead of {@link #DEFAULT_SITE_STRING}. + */ + public DepotSiteIdentifier ( + PersistenceContext pctx, int defaultSiteId, String defaultSiteString) + { + _repo = new SiteIdentifierRepository(pctx); + refreshSiteData(); + _defaultSiteId = defaultSiteId; + _defaultSiteString = defaultSiteString; + } + + // documentation inherited + public int identifySite (HttpServletRequest req) + { + checkReloadSites(); + String serverName = req.getServerName(); + + // scan for the mapping that matches the specified domain + int msize = _mappings.size(); + for (int i = 0; i < msize; i++) { + SiteMapping mapping = _mappings.get(i); + if (serverName.endsWith(mapping.domain)) { + return mapping.siteId; + } + } + + // if we matched nothing, return the default id + return _defaultSiteId; + } + + // documentation inherited + public String getSiteString (int siteId) + { + checkReloadSites(); + Site site = _sitesById.get(siteId); + if (site == null) { + site = _sitesById.get(_defaultSiteId); + } + return (site == null) ? _defaultSiteString : site.siteString; + } + + // documentation inherited + public int getSiteId (String siteString) + { + checkReloadSites(); + Site site = _sitesByString.get(siteString); + return (site == null) ? _defaultSiteId : site.siteId; + } + + // documentation inherited from interface + public Iterator enumerateSites () + { + checkReloadSites(); + return _sitesById.values().iterator(); + } + + /** + * Insert a new site into the site table and into this mapping. + */ + public Site insertNewSite (String siteString) + { + return insertNewSite(siteString, 0); + } + + /** + * Insert a new site into the site table and into this mapping. + */ + public Site insertNewSite (String siteString, int siteId) + { + if (_sitesByString.containsKey(siteString) || + (siteId > 0 && _sitesById.containsKey(siteId))) { + return null; + } + + // add it to the db + Site site = _repo.insertNewSite(siteString, siteId); + + // add it to our two mapping tables, taking care to avoid causing enumerateSites() to choke + Map newStrings = Maps.newHashMap(_sitesByString); + Map newIds = Maps.newHashMap(_sitesById); + newIds.put(site.siteId, site); + newStrings.put(site.siteString, site); + _sitesByString = newStrings; + _sitesById = newIds; + + return site; + } + + /** + * Insert a new domain into the domain table and into this mapping. + */ + public void insertNewDomain (String domain, int siteId) + { + // scan for the mapping that matches the specified domain + int msize = _mappings.size(); + for (int i = 0; i < msize; i++) { + SiteMapping mapping = _mappings.get(i); + if (mapping.domain.equals(domain)) { + return; + } + } + + // add it to the db + _repo.insertNewDomain(domain, siteId); + + // add it to our two mapping table, taking care to avoid causing enumerateSites() to choke + List mappings = Lists.newArrayList(); + mappings.addAll(_mappings); + mappings.add(new SiteMapping(siteId, domain)); + + Collections.sort(mappings, SiteMapping.BY_SPECIFICITY); + _mappings = mappings; + } + + /** + * Checks to see if we should reload our sites information from the sites table. + */ + protected void checkReloadSites () + { + long now = System.currentTimeMillis(); + boolean reload = false; + synchronized (this) { + reload = (now - _lastReload > RELOAD_INTERVAL); + if (reload) { + _lastReload = now; + } + } + if (reload) { + refreshSiteData(); + } + } + + /** + * Refreshes the cached site information. + */ + public void refreshSiteData () + { + List siteRecords = _repo.loadSites(); + Map sites = Maps.newHashMap(); + Map strings = Maps.newHashMap(); + for (SiteRecord record : siteRecords) { + Site site = record.toSite(); + sites.put(record.siteId, site); + strings.put(record.siteString, site); + } + _sitesById = sites; + _sitesByString = strings; + + List domainRecords = _repo.loadDomains(); + List mappings = Lists.newArrayList(); + for (DomainRecord record : domainRecords) { + mappings.add(new SiteMapping(record.siteId, record.domain)); + } + + Collections.sort(mappings, SiteMapping.BY_SPECIFICITY); + _mappings = mappings; + } + + /** + * Used to track domain to site identifier mappings. + */ + protected static class SiteMapping + { + /** + * Sorts site mappings from most specific (www.yahoo.com) to least specific (yahoo.com). + */ + public static final Comparator BY_SPECIFICITY = new Comparator() { + public int compare (SiteMapping one, SiteMapping two) { + return one._rdomain.compareTo(two._rdomain); + } + }; + + /** The domain to match. */ + public String domain; + + /** The site identifier for the associated domain. */ + public int siteId; + + public SiteMapping (int siteId, String domain) { + this.siteId = siteId; + this.domain = domain; + byte[] bytes = domain.getBytes(); + ArrayUtil.reverse(bytes); + _rdomain = new String(bytes); + } + + @Override // from Object + public String toString () { + return "[" + domain + " => " + siteId + "]"; + } + + protected String _rdomain; + } + + /** The repository through which we load up site identifier information. */ + protected SiteIdentifierRepository _repo; + + /** The site id to return if we cannot identify the site from our table data. */ + protected int _defaultSiteId; + + /** The site string to return if we cannot identify the site from our table data. */ + protected String _defaultSiteString; + + /** The list of domain to site identifier mappings ordered from most specific domain to least + * specific. */ + protected volatile List _mappings = Lists.newArrayList(); + + /** The mapping from integer site identifiers to string site identifiers. */ + protected volatile Map _sitesById = Maps.newHashMap(); + + /** The mapping from string site identifiers to integer site identifiers. */ + protected volatile Map _sitesByString = Maps.newHashMap(); + + /** Used to periodically reload our site data. */ + protected long _lastReload; + + /** Reload our site data every 15 minutes. */ + protected static final long RELOAD_INTERVAL = 15 * 60 * 1000L; +} + diff --git a/src/main/java/com/threerings/servlet/persist/DomainRecord.java b/src/main/java/com/threerings/servlet/persist/DomainRecord.java new file mode 100644 index 0000000..52e6dbf --- /dev/null +++ b/src/main/java/com/threerings/servlet/persist/DomainRecord.java @@ -0,0 +1,48 @@ +// +// $Id$ + +package com.threerings.servlet.persist; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.servlet.SiteIdentifier; + +/** + * Represents a domain mapping known to a {@link SiteIdentifier}. + */ +@Entity(name="domains") +public class DomainRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = DomainRecord.class; + public static final ColumnExp DOMAIN = colexp(_R, "domain"); + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The domain. */ + @Id @Column(length=128) + public String domain; + + /** The site identifier. */ + public int siteId; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link DomainRecord} + * with the supplied key values. + */ + public static Key getKey (String domain) + { + return newKey(_R, domain); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(DOMAIN); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/servlet/persist/SiteIdentifierRepository.java b/src/main/java/com/threerings/servlet/persist/SiteIdentifierRepository.java new file mode 100644 index 0000000..b2dd306 --- /dev/null +++ b/src/main/java/com/threerings/servlet/persist/SiteIdentifierRepository.java @@ -0,0 +1,84 @@ +// +// $Id$ + +package com.threerings.servlet.persist; + +import java.util.List; +import java.util.Set; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.servlet.Site; + +/** + * Depot implements of a site identifier repository. + */ +@Singleton +public class SiteIdentifierRepository extends DepotRepository +{ + /** + * Creates the repository. + */ + @Inject public SiteIdentifierRepository (PersistenceContext ctx) + { + super(ctx); + } + + /** + * Loads the sites. + */ + public List loadSites () + { + return findAll(SiteRecord.class); + } + + /** + * Loads the domains. + */ + public List loadDomains () + { + return findAll(DomainRecord.class); + } + + /** + * Adds a new site. + */ + public Site insertNewSite (String siteString) + { + return insertNewSite(siteString, 0); + } + + /** + * Adds a new site. + */ + public Site insertNewSite (String siteString, int siteId) + { + SiteRecord record = new SiteRecord(); + record.siteString = siteString; + record.siteId = siteId; + insert(record); + return record.toSite(); + } + + /** + * Adds a new domain. + */ + public void insertNewDomain (String domain, int siteId) + { + DomainRecord record = new DomainRecord(); + record.domain = domain; + record.siteId = siteId; + insert(record); + } + + @Override // documentation inherited + protected void getManagedRecords (Set> classes) + { + classes.add(DomainRecord.class); + classes.add(SiteRecord.class); + } +} diff --git a/src/main/java/com/threerings/servlet/persist/SiteRecord.java b/src/main/java/com/threerings/servlet/persist/SiteRecord.java new file mode 100644 index 0000000..157a77b --- /dev/null +++ b/src/main/java/com/threerings/servlet/persist/SiteRecord.java @@ -0,0 +1,64 @@ +// +// $Id$ + +package com.threerings.servlet.persist; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; + +import com.samskivert.servlet.Site; +import com.samskivert.servlet.SiteIdentifier; + +/** + * Represents a site mapping known to a {@link SiteIdentifier}. + */ +@Entity(name="sites") +public class SiteRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = SiteRecord.class; + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + public static final ColumnExp SITE_STRING = colexp(_R, "siteString"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The site's unique identifier. */ + @Id @GeneratedValue(strategy=GenerationType.AUTO) + public int siteId; + + /** The site's human readable identifier (i.e., "monkeybutter"). */ + @Column(length=24) + public String siteString; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link SiteRecord} + * with the supplied key values. + */ + public static Key getKey (int siteId) + { + return newKey(_R, siteId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(SITE_ID); } + // AUTO-GENERATED: METHODS END + + /** + * Creates a Site from this record. + */ + public Site toSite () + { + Site site = new Site(); + site.siteId = siteId; + site.siteString = siteString; + return site; + } +} diff --git a/src/main/java/com/threerings/user/ABTestRepository.java b/src/main/java/com/threerings/user/ABTestRepository.java new file mode 100644 index 0000000..0ece2d0 --- /dev/null +++ b/src/main/java/com/threerings/user/ABTestRepository.java @@ -0,0 +1,118 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.ResultSet; +import java.sql.Statement; +import java.sql.PreparedStatement; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.SimpleRepository; +import com.samskivert.util.IntTuple; + +/** + * Manages database access for figuring out if we're doing an AB test of the website, and if so, + * keeping track of how many have gone each way. + */ +public class ABTestRepository extends SimpleRepository +{ + /** + * The database identifier used when establishing a database + * connection. This value being abtestdb. + */ + public static final String ABTEST_DB_IDENT = "abtestdb"; + + /** + * Creates the repository and prepares it for operation. + * + * @param provider the database connection provider. + */ + public ABTestRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, ABTEST_DB_IDENT); + } + + /** + * Looks up whether or not we're doing an AB test, and what the remaining desired number + * of people for each bucket is. + * + * Returns null if there's no test running. + */ + public IntTuple getABTestCounts () + throws PersistenceException + { + return execute(new Operation() { + public IntTuple invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + ResultSet rs = + stmt.executeQuery("select REMAINING_A, REMAINING_B from AB_TEST"); + while (rs.next()) { + int a = rs.getInt(1); + int b = rs.getInt(2); + + if (a != 0 && b != 0) { + return new IntTuple(a,b); + } + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + public void decrementABTest (final boolean groupA) + throws PersistenceException + { + final IntTuple counts = getABTestCounts(); + + if (counts == null) { + // Not running a test? Ignore it + return; + } + + if (counts.left == -1) { + // Can set to -1/-1 to show we want it to go on indefinitely + return; + } + + executeUpdate(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + PreparedStatement stmt = conn.prepareStatement( + "update AB_TEST set REMAINING_" + (groupA ? "A" : "B") + + "=" + (Math.max(0, (groupA ? counts.left : counts.right) - 1))); + + try { + JDBCUtil.checkedUpdate(stmt, 1); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + @Override + protected void migrateSchema (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String[] abTestTable = { + "REMAINING_A INTEGER", + "REMAINING_B INTEGER", + }; + JDBCUtil.createTableIfMissing(conn, "AB_TEST", abTestTable, ""); + } +} diff --git a/src/main/java/com/threerings/user/AccountAction.java b/src/main/java/com/threerings/user/AccountAction.java new file mode 100644 index 0000000..d428ef3 --- /dev/null +++ b/src/main/java/com/threerings/user/AccountAction.java @@ -0,0 +1,78 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Timestamp; + +import com.samskivert.util.StringUtil; + +/** + * Representation of a AccountAction record from the database. + */ +public class AccountAction +{ + /** An action constant indicating the first time a user becomes a + * subscriber, and have also never before bought coins or time. */ + public static final int INITIAL_SUBSCRIPTION = 0; + + /** An action constant indicating that some server has modified the + * coins count for a particular user. */ + public static final int COINS_UPDATED = 1; + + /** An action constant indicating that the user, whom at some point in + * the past used to be a subscriber, has become a subscriber again. */ + public static final int REPEAT_SUBSCRIPTION = 2; + + /** An action constant indicating that the user purchased coins for the + * first time ever, and have never before subscribed or bought time. */ + public static final int INITIAL_COIN_PURCHASE = 3; + + /** An action constant indicating that the specified account has + * expired and any player resources should be expired. The 'data' + * field is currently filled in with the yohoho accountId for the + * expired account. */ + public static final int ACCOUNT_EXPIRED = 4; + + /** An action constant indicating that the specified account has been + * deleted in the external account system. The 'data' field is + * optionally filled in with the "disabled" username which the server + * can use to simply disable the account rather than deleting it. */ + public static final int ACCOUNT_DELETED = 5; + + /** An action unconnected with any single account, but rather a system-wide + * signal to all servers that they should do whatever "daily" actions + * they would like to do at the present time. */ + public static final int DO_DAILY_ACTIONS = 6; + + /** An action constant indicating that the user purchased time for the + * first time ever, and has never before subscribed or bought coins. */ + public static final int INITIAL_TIME_PURCHASE = 7; + + /** Indicates that a reward has been activated for the account. */ + public static final int REWARD_ACTIVATED = 8; + + /** Placeholder for application-defined action constants. */ + public static final int FIRST_APPLICATION_ACTION = 100; + + /** Unique action identifier. */ + public int actionId; + + /** The unique identifier for the account. */ + public String accountName; + + /** The action that took place. */ + public int action; + + /** Data that is interpreted depending on the action type. */ + public String data; + + /** When the action took place. */ + public Timestamp entered; + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/AccountActionRepository.java b/src/main/java/com/threerings/user/AccountActionRepository.java new file mode 100644 index 0000000..fd8e843 --- /dev/null +++ b/src/main/java/com/threerings/user/AccountActionRepository.java @@ -0,0 +1,401 @@ +// +// $Id$ + +package com.threerings.user; + +import static com.threerings.user.Log.log; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.base.Joiner; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.JORARepository; +import com.samskivert.jdbc.jora.Table; +import com.samskivert.util.ArrayIntSet; +import com.samskivert.util.StringUtil; + +/** + * Provides access to the account actions table. + * + * @deprecated Use com.threerings.user.depot.AccountActionRepository instead. + */ +@Deprecated +public class AccountActionRepository extends JORARepository +{ + /** + * The database identifier used when establishing a database connection. + * This value being actiondb. + */ + public static final String ACTION_DB_IDENT = "actiondb"; + + /** + * Creates the repository and prepares it for operation. + * + * @param provider the database connection provider. + */ + public AccountActionRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, ACTION_DB_IDENT); + + // figure out whether or not we should be disabled + execute(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + _active = JDBCUtil.tableExists(conn, "ACCOUNT_ACTIONS"); + if (!_active) { + log.info("No actions table. Disabling."); + } + return null; + } + }); + } + + /** + * Return the list of actions that have not yet been processed by the specified server. + * Returns all actions if no server name is specified. + * + * Note: this method has two side effects: the first time it is called, it will + * register this server as a action participant (assuming server is non-null) and + * subsequently, it will call {@link #pruneActions} if it has not done so within the last + * hour. + */ + public List getActions (String server) + throws PersistenceException + { + return getActions(server, Integer.MAX_VALUE); + } + + /** + * Return the list of actions that have not yet been processed by the specified server. + * Returns all actions if no server name is specified. + * + * @param maxActions the maximum number of actions to return. + * + * Note: this method has two side effects: the first time it is called, it will + * register this server as a action participant (assuming server is non-null) and + * subsequently, it will call {@link #pruneActions} if it has not done so within the last + * hour. + */ + public List getActions (String server, int maxActions) + throws PersistenceException + { + if (!_active) { + return new ArrayList(); + } + + // look ma, subselects + String where = "where ACTION_ID NOT IN " + + "(select ACTION_ID from PROCESSED_ACTIONS " + + "where SERVER = '" + server + "') limit " + maxActions; + List list = loadAll(_atable, where); + + // unjigger the account names + for (AccountAction action : list) { + action.accountName = JDBCUtil.unjigger(action.accountName); + } + + // no fooling around if we are acting in general + if (StringUtil.isBlank(server)) { + return list; + } + + // if this is the first time this method is called, register ourselves + // with the action system + long now = System.currentTimeMillis(); + if (_lastActionPrune == 0L) { + _lastActionPrune = now; + try { + registerActionServer(server); + } catch (PersistenceException pe) { + log.warning("Failure registering server", "server", server, pe); + } + + } else if (now - _lastActionPrune > ACTION_PRUNE_INTERVAL) { + _lastActionPrune = now; + try { + pruneActions(); + } catch (PersistenceException pe) { + log.warning("Failure auto-pruning actions", "server", server, pe); + } + } + + return list; + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, int action) + throws PersistenceException + { + addAction(accountName, null, action, ""); + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, String data, int action) + throws PersistenceException + { + addAction(accountName, data, action, ""); + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, int action, String server) + throws PersistenceException + { + addAction(accountName, null, action, server); + } + + /** + * Adds a new action to the repository, with the specified server being marked as already + * having processed the action. + */ + public void addAction (String accountName, String data, int action, final String server) + throws PersistenceException + { + if (!_active) { + log.info("Dropping account action", + "name", accountName, "data", data, "action", action, "server", server); + return; + } + + // create and insert the account action + final AccountAction aact = new AccountAction(); + aact.accountName = JDBCUtil.jigger(accountName); + aact.data = data; + aact.action = action; + aact.entered = new Timestamp(System.currentTimeMillis()); + aact.actionId = insert(_atable, aact); + + // note that it was processed by this server if appropriate + if (!StringUtil.isBlank(server)) { + noteProcessed(aact.actionId, server); + } + } + + /** + * Updates the single specified action to reflect that the specified server has processed it. + */ + public void updateAction (AccountAction action, String server) + throws PersistenceException + { + updateActions(Collections.singletonList(action), server); + } + + /** + * Batch update a list of actions. + */ + public void updateActions (List actions, String server) + throws PersistenceException + { + for (int ii = 0, ll = actions.size(); ii < ll; ii++) { + noteProcessed(actions.get(ii).actionId, server); + } + } + + /** + * Deletes an action from the repository. + */ + public void deleteAction (final AccountAction action) + throws PersistenceException + { + // delete the action + delete(_atable, action); + + // and any records indicating it has been processed + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + stmt.executeUpdate("delete from PROCESSED_ACTIONS " + + "where ACTION_ID = " + action.actionId); + } finally { + stmt.close(); + } + return null; + } + }); + } + + /** + * Notes that the specified server has processed the sepecified action. + */ + public void noteProcessed (final int actionId, final String server) + throws PersistenceException + { + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Statement stmt = conn.createStatement(); + try { + String query = "insert into PROCESSED_ACTIONS " + + "values(" + actionId + ", '" + server + "')"; + stmt.executeUpdate(query); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Prunes actions that have been processed by all registered servers to keep the actions table + * small. This method need not be called by hand as it is called automatically by + * {@link #getActions(String,int)}, but no more frequently than once an hour. + */ + public void pruneActions () + throws PersistenceException + { + final Set servers = loadActionServers(); + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Map> procmap = Maps.newHashMap(); + ArrayIntSet actids = new ArrayIntSet(); + Statement stmt = conn.createStatement(); + try { + // determine which servers have processed which actions + ResultSet rs = stmt.executeQuery( + "select ACTION_ID, SERVER from PROCESSED_ACTIONS"); + while (rs.next()) { + Integer actionId = (Integer)rs.getObject(1); + Set procset = procmap.get(actionId); + if (procset == null) { + procmap.put(actionId, procset = Sets.newHashSet()); + } + procset.add(rs.getString(2)); + } + + // determine which of those have been fully processed + for (Map.Entry> entry : procmap.entrySet()) { + Integer actionId = entry.getKey(); + Set procset = entry.getValue(); + if (procset.containsAll(servers)) { + actids.add(actionId.intValue()); + } + } + + // now wipe out the actions (and processed entries) for + // all actions that have been fully processed + if (actids.size() > 0) { + String where = "where ACTION_ID in " + + "(" + Joiner.on(",").join(actids) + ")"; + stmt.executeUpdate("delete from ACCOUNT_ACTIONS " + where); + stmt.executeUpdate("delete from PROCESSED_ACTIONS " + where); + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Registers a server with the action system. This method is called automatically the first + * time {@link #getActions(String)} is called. + */ + protected void registerActionServer (final String server) + throws PersistenceException + { + // see if we're already registered + Set set = loadActionServers(); + if (set.contains(server)) { + return; + } + + // if not, insert ourselves into the action table + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String query = "insert into ACTION_SERVERS (SERVER) values (?)"; + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement(query); + stmt.setString(1, server); + stmt.executeUpdate(); + + return null; + } finally { + JDBCUtil.close(stmt); + } + } + }); + + log.info("Registered action server '" + server + "'."); + } + + /** + * Loads up the set of action servers. + */ + protected Set loadActionServers () + throws PersistenceException + { + return execute(new Operation>() { + public Set invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + Set set = Sets.newHashSet(); + String query = "select * from ACTION_SERVERS"; + Statement stmt = null; + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + set.add(rs.getString(1)); + } + } finally { + JDBCUtil.close(stmt); + } + return set; + } + }); + } + + @Override + protected void createTables () + { + _atable = new Table(AccountAction.class, TABLE, "ACTION_ID", true); + } + + /** The table used to note actions that have taken place. */ + protected Table _atable; + + /** Whether or not we're actually being used. */ + protected boolean _active; + + /** Used by {@link #getActions(String)}. */ + protected long _lastActionPrune; + + /** We automatically prune the actions table once an hour. */ + protected static final long ACTION_PRUNE_INTERVAL = 60 * 60 * 1000L; + + /** The name of the account actions table. */ + protected static final String TABLE = "ACCOUNT_ACTIONS"; +} diff --git a/src/main/java/com/threerings/user/AffiliateInfo.java b/src/main/java/com/threerings/user/AffiliateInfo.java new file mode 100644 index 0000000..dc95b54 --- /dev/null +++ b/src/main/java/com/threerings/user/AffiliateInfo.java @@ -0,0 +1,647 @@ +// +// $Id$ + +package com.threerings.user; + +import static com.threerings.user.Log.log; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.samskivert.io.PersistenceException; +import com.samskivert.servlet.SiteIdentifier; +import com.samskivert.servlet.util.CookieUtil; +import com.samskivert.servlet.util.ParameterUtil; +import com.samskivert.util.StringUtil; + +public abstract class AffiliateInfo +{ + /** The name of the session attribute used to hold the affiliate suffix */ + public static final String AFFILIATE_SUFFIX_ATTRIBUTE = + AffiliateInfo.class.getName() + ".AffiliateSuffix"; + + /** + * Class determining the behavior if the affiliate is 'new referral' i.e. the URL + * has a 'from' and optionally, a 'tag' parameter. + */ + protected static class NewReferral extends AffiliateInfo + { + protected final String fromParameter; + + protected final int siteId; + + protected final int tagId; + + public NewReferral (String fromParameter, int siteId, int tagId) + { + this.fromParameter = fromParameter; + this.siteId = siteId; + this.tagId = tagId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + if (isPersonalSuffix(fromParameter)) { + setAffiliateSuffix(req, fromParameter, tagId); + } else { + setAffiliateSuffix(req, "" + siteId, tagId); + } + } + + @Override + public String getAffiliateName () + { + return fromParameter; + } + + @Override + public boolean hasName () + { + return !isPersonalSuffix(fromParameter); + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, "" + tagId); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + if (isPersonalSuffix(fromParameter)) { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, fromParameter); + } else { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + siteId); + } + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO probably need nothing + giveCookie(req, rsp, SITE_REFER_COOKIE, fromParameter); + } + + @Override + public int getTagId () + { + return tagId; + } + } + + /** + * Class determining the behavior if the wasn't referred to us in this particular + * request, but they were referred to us before, and we've recorded that in cookies. + */ + protected static class CurrentCookies extends AffiliateInfo + { + protected final String siteIdCookieValue; + + protected final int tagIdCookieValue; + + public CurrentCookies (String siteIdCookieValue, int tagIdCookieValue) + { + this.siteIdCookieValue = siteIdCookieValue; + this.tagIdCookieValue = tagIdCookieValue; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, siteIdCookieValue, tagIdCookieValue); + } + + @Override + public String getAffiliateName () + { + return null; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, "" + tagIdCookieValue); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, siteIdCookieValue); + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, SITE_REFER_COOKIE, ""); + } + + @Override + public int getTagId () + { + return tagIdCookieValue; + } + } + + /** + * Class determining the behavior if the user has an 'old-style' cookie indicating + * that they came to us as a referral. + */ + protected static class LegacyCookies extends AffiliateInfo + { + protected final String siteRefer; + + protected final int siteId; + + public LegacyCookies (int siteId, String siteReferCookie) + { + this.siteRefer = siteReferCookie; + this.siteId = siteId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + if (isPersonalSuffix(siteRefer)) { + setAffiliateSuffix(req, siteRefer, AffiliateTag.NO_TAG); + } else { + setAffiliateSuffix(req, "" + siteId, AffiliateTag.NO_TAG); + } + } + + @Override + public String getAffiliateName () + { + if (!isPersonalSuffix(siteRefer)) { + return siteRefer; + } else { + return ""; + } + } + + @Override + public boolean hasName () + { + return !isPersonalSuffix(siteRefer); + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO: should we actually be removing the cookie here + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, ""); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + if (isPersonalSuffix(siteRefer)) { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, siteRefer); + } else { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + siteId); + } + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO: should be be removing this cookie? + giveCookie(req, rsp, SITE_REFER_COOKIE, siteRefer); + } + + @Override + public int getTagId () + { + return AffiliateTag.NO_TAG; + } + } + + /** + * Define the behavior if we don't have any affiliate information but we want to track the user + * in a separate category from the 'default site'. + */ + protected static class AlternateDefault extends AffiliateInfo + { + protected final int site; + + protected AlternateDefault (int site) + { + this.site = site; + } + + @Override + public boolean isDefaultAffiliate () + { + return true; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, "" + site, AffiliateTag.NO_TAG); + } + + @Override + public String getAffiliateName () + { + return ""; + } + + @Override + public int getTagId () + { + return AffiliateTag.NO_TAG; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // Don't issue a cookie; + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + site); + } + + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // Don't issue a cookie; + } + } + + /** + * Determines the behavior if a tag cookie exists, but not an affiliate (site id) cookie. + * "0" will be supplied as the affiliate to the 'affsuf' context attribute. + */ + protected static class TagOnly extends AffiliateInfo + { + protected final int tagId; + + public TagOnly (int tagId) + { + this.tagId = tagId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, "0", tagId); + } + + @Override + public String getAffiliateName () + { + return ""; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, String.valueOf(tagId)); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // do not issue a cookie + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // do not issue a cookie + } + + @Override + public int getTagId () + { + return tagId; + } + } + + /** + * Find out whether we know the name of the affiliate. + */ + public abstract boolean hasName (); + + /** + * Get the name of the affiliate. + * + * @return - The name as a string. + */ + public abstract String getAffiliateName (); + + /** + * Get the id for the affiliate tag. + */ + public abstract int getTagId (); + + /** + * Compute the appropriate affiliate 'affsuf' and add it to the session along with the + * 'affid' used by some of the web templates. Now with less Velocity. + */ + public abstract void addToContext (HttpServletRequest req); + + /** + * Issue the site_id cookie to the browser (non-velocity). + */ + public abstract void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Issue the site_refer cookie to the browser. + */ + public abstract void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Issue the affiliate_tag cookie to the browser (non-velocity). + */ + public abstract void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Normally returns false, but can be overridden to indicate that we've been given + * a cookie with a default affiliate passed in to us. "Default" isn't quite the right word + * since this is really used for letting pages hardwire their default affiliate to be something + * other than the default, but everything else uses this word, and I don't have a better idea + * offhand. + */ + public boolean isDefaultAffiliate () + { + return false; + } + + /** + * Make an affiliate info object from the cookies and parameters in the request. + */ + public static AffiliateInfo getAffiliateInfo ( + OOOUserManager usermgr, ReferralRepository repository, SiteIdentifier identifier, + HttpServletRequest req, boolean parseFromParameter) throws PersistenceException + { + int siteId = identifier.identifySite(req); + return getAffiliateInfo(usermgr, repository, identifier, req, siteId, parseFromParameter); + } + + /** + * Make an affiliate info object from the cookies and parameters in the request. + * + * @param alternateDefault - override default affiliate (hard-coded into affiliate pages) + * @param parseFromParameter - Should we look at the request parameter "from"? + * @return - The affiliate info or null if there wasn't enough information in the request to + * construct it. + * @throws PersistenceException - Thrown if accessing the ooouser database failed. + */ + public static AffiliateInfo getAffiliateInfo ( + OOOUserManager usermgr, ReferralRepository repository, SiteIdentifier identifier, + HttpServletRequest req, int alternateDefault, + boolean parseFromParameter) throws PersistenceException + { + // if they're already a user with us, bail + if (null != usermgr.loadUser(req)) { + return null; + } + + String fromParameter = parseFromParameter ? parseFromParameter(req) : ""; + + // is this a new referral - i.e. this request was referred from a referral link? + if (!StringUtil.isBlank(fromParameter)) { + String tagParameter = readAffiliateTag(req); + + int siteId = parseReferrer(repository, identifier, fromParameter); + if (siteId == 0) { + log.warning("User referred by bogus referrer [referrer=" + fromParameter + "]."); + return null; + } + + if (!StringUtil.isBlank(tagParameter) && !isPersonalSuffix(fromParameter)) { + int tagId = usermgr.getAffiliateTagId(tagParameter); + + return new NewReferral(fromParameter, siteId, tagId); + } else { + return new NewReferral(fromParameter, siteId, AffiliateTag.NO_TAG); + } + } + + // do we have modern cookies for this referral? + String siteIdCookieValue = readSiteIdCookie(req); + if (!StringUtil.isBlank(siteIdCookieValue)) { + int tagIdCookieValue = readTagId(req); + + return new CurrentCookies(siteIdCookieValue, tagIdCookieValue); + } + + // do we have old fashioned cookies for this referral? + String siteReferCookie = CookieUtil.getCookieValue(req, SITE_REFER_COOKIE); + if (!StringUtil.isBlank(siteReferCookie)) { + int siteId = parseReferrer(repository, identifier, siteReferCookie); + return new LegacyCookies(siteId, siteReferCookie); + } + + // we have no useful affiliate information. If we have been provided with an alternative + // default site, then use that as the site-id. + if (alternateDefault != identifier.identifySite(req)) { + return new AlternateDefault(alternateDefault); + } + + // we have a tag cookie but no affiliate cookie + int tagIdCookieValue = readTagId(req); + if (tagIdCookieValue != AffiliateTag.NO_TAG) { + return new TagOnly(tagIdCookieValue); + } + + // we couldn't figure out anything useful to do with whatever affiliate related info + // we had, so we return null and the caller can do nothing. + return null; + } + + /** + * @param repository For verifying referrer ids against the database + * @param identifier Velocity site repository + * @param referrer Referral request string, may be a personal referrer of + * the format r[0-9]+ or user-[0-9]+, or a site referrer of format [0-9]+ + * @return the referrer siteId, which may be a positive or negative number, + * or 0 if no match. + */ + public static int parseReferrer (ReferralRepository repository, SiteIdentifier identifier, + String referrer) + { + // blank referrer string + if (StringUtil.isBlank(referrer)) { + return 0; + } + + // handle new-style personal referrals + if (referrer.matches("r[0-9]+")) { + int refId = 0; + try { + refId = Integer.parseInt(referrer.substring(1)); + } catch (NumberFormatException nfe) { + log.warning("Bogus user referral: " + referrer); + return 0; + } + + // look up the record in the referrer repository + ReferralRecord rec = null; + try { + rec = repository.lookupReferral(refId); + return (rec == null) ? OOOUser.DEFAULT_SITE_ID : (-1 * rec.referrerId); + } catch (PersistenceException pe) { + log.warning("Error looking up referral " + "[ref=" + referrer + ", error=" + pe + + "]."); + return 0; + } + } + + // handle old-style personal referrals + if (referrer.startsWith("user-")) { + try { + return -1 * Integer.parseInt(referrer.substring(5)); + } catch (NumberFormatException nfe) { + log.warning("Bogus user referral: " + referrer); + return 0; + } + } + + // check referrer string against site repository + int site = identifier.getSiteId(referrer); + if (site != -1) { + return site; + } + log.warning("Bogus site specified in referrer " + "[value=" + referrer + "]."); + return 0; + } + + /** + * Parse the contents of the 'from' parameter. + */ + public static String parseFromParameter (HttpServletRequest req) + { + String value = ParameterUtil.getParameter(req, "from", true); + if (value != null) { + // strip the crap some affiliates append to their ID + Matcher am = _affregex.matcher(value); + if (am.find()) { + value = am.group(); + } + } + + return value; + } + + /** + * Find out whether the affiliate name is actually a personal referral as + * opposed to a site referral. + * + * @return - True if the affiliate name is a personal referral. False if + * it's a site or unknown. + */ + protected static boolean isPersonalSuffix (String name) + { + return name != null && name.matches("r[0-9]+"); + } + + /** + * Get the value stored in the site id cookie. + */ + protected static String readSiteIdCookie (HttpServletRequest req) + { + return CookieUtil.getCookieValue(req, AffiliateUtils.AFFILIATE_ID_COOKIE); + } + + /** + * Calculate and add the affiliate id and affiliate suffix to the session. No longer + * added to the Velocity context; this is velocity-independant. + * @param req + * @param referrer + * @param tagId + */ + protected void setAffiliateSuffix (HttpServletRequest req, String referrer, int tagId) + { + if (tagId != AffiliateTag.NO_TAG) { + req.getSession().setAttribute(AFFILIATE_SUFFIX_ATTRIBUTE, "-" + referrer + "-" + tagId); + } else { + req.getSession().setAttribute(AFFILIATE_SUFFIX_ATTRIBUTE, "-" + referrer); + } + + req.getSession().setAttribute(AffiliateUtils.AFFILIATE_ID_ATTRIBUTE, referrer); + } + + /** + * Add a cookie to the response (velocity independant). + * @param req servlet request object + * @param rsp sevlet response object + * @param name cookie identifier + * @param value value for the cookie + */ + protected static void giveCookie (HttpServletRequest req, HttpServletResponse rsp, + String name, String value) + { + Cookie cookie = new Cookie(name, value); + // cookie expires in one month + cookie.setMaxAge(30 * 24 * 60 * 60); + // set the path and widened domain (eg www.domain.com -> .domain.com) of the cookie + cookie.setPath("/"); + CookieUtil.widenDomain(req, cookie); + rsp.addCookie(cookie); + } + + /** + * Get the affiliate tag from the tag parameter in the request. + * + * @param req - The request. + * @return - The tag string. + */ + protected static String readAffiliateTag (HttpServletRequest req) + { + return req.getParameter("tag"); + } + + protected static int readTagId (HttpServletRequest req) + { + // if they have an affiliate tag cookie, get it. + String cookie = CookieUtil.getCookieValue(req, AffiliateUtils.AFFILIATE_TAG_COOKIE); + if (cookie != null) { + try { + return Integer.parseInt(cookie); + } catch (NumberFormatException e) { + return AffiliateTag.NO_TAG; + } + } else { + return AffiliateTag.NO_TAG; + } + } + + /** A regexp that matches just the proper parts of an affiliate id. */ + protected static Pattern _affregex = Pattern.compile(OOOUser.SITE_STRING_REGEX); + + /** + * The name of the legacy cookie used to store the affiliate name passed by + * them to us with their original request. + */ + protected static final String SITE_REFER_COOKIE = "site_refer"; +} diff --git a/src/main/java/com/threerings/user/AffiliateTag.java b/src/main/java/com/threerings/user/AffiliateTag.java new file mode 100644 index 0000000..55814ab --- /dev/null +++ b/src/main/java/com/threerings/user/AffiliateTag.java @@ -0,0 +1,20 @@ +// +// $Id$ + +package com.threerings.user; + +/** + * Maintains a mapping from an arbitrary text tag to an integer identifier. + */ +public class AffiliateTag +{ + /** Value indicating that the affiliate did not provide a tag **/ + public static final int NO_TAG = 0; + + /** The automatically generated tag id. */ + public int tagId; + + /** The arbitrary text tag. */ + public String tag; + +} diff --git a/src/main/java/com/threerings/user/AffiliateUtils.java b/src/main/java/com/threerings/user/AffiliateUtils.java new file mode 100644 index 0000000..c73856f --- /dev/null +++ b/src/main/java/com/threerings/user/AffiliateUtils.java @@ -0,0 +1,40 @@ +// +// $Id$ + +/* + * Copyright (C) 2007-2010 Three Rings Design, Inc. + * Copyright 1999,2004 The Apache Software Foundation. + */ + +package com.threerings.user; + +/** + * Utilities for dealing with affiliate tagging. + */ +public class AffiliateUtils { + + /** Request parameter containing integer affiliate site id. */ + public static final String AFFILIATE_ID_PARAMETER = "affiliate"; + + /** before the days of affiliate, there was from, + * which also supports referrals from in-game users */ + public static final String FROM_PARAMETER = "from"; + + /** Request parameter containing sub-affiliate tag; an arbitrary integer usable + * by the partner to distinguish different placements on their site. */ + public static final String AFFILIATE_TAG_PARAMETER = "tag"; + + /** Affiliate cookie name. + * this cookie is also used for legacy personal referrals */ + public static final String AFFILIATE_ID_COOKIE = "site_id"; + + /** Affiliate arbitrary tag cookie name. */ + public static final String AFFILIATE_TAG_COOKIE = "affiliate_tag"; + + /** The name of the session attribute used to hold the affiliate id */ + public static final String AFFILIATE_ID_ATTRIBUTE = AffiliateUtils.class.getName() + ".AffiliateId"; + + /** The name of the session attribute used to hold the affiliate tag */ + public static final String AFFILIATE_TAG_ATTRIBUTE = AffiliateUtils.class.getName() + ".AffiliateTag"; + +} diff --git a/src/main/java/com/threerings/user/BannedIdent.java b/src/main/java/com/threerings/user/BannedIdent.java new file mode 100644 index 0000000..83ca8ac --- /dev/null +++ b/src/main/java/com/threerings/user/BannedIdent.java @@ -0,0 +1,22 @@ +// +// $Id$ + +package com.threerings.user; + +/** + * Represents a row in the BANNED_IDENTS table. + */ +public class BannedIdent +{ + /** A 'unique' id for a specific machine we have seen. */ + public String machIdent; + + /** The site id which this machine is banned from. */ + public int siteId; + + @Override + public String toString () + { + return "(" + siteId + ") " + machIdent; + } +} diff --git a/src/main/java/com/threerings/user/BlastVerify.java b/src/main/java/com/threerings/user/BlastVerify.java new file mode 100644 index 0000000..8f32b4b --- /dev/null +++ b/src/main/java/com/threerings/user/BlastVerify.java @@ -0,0 +1,122 @@ +// +// $Id$ + +package com.threerings.user; + +import java.io.IOException; + +import java.net.MalformedURLException; +import java.net.URL; + +import java.util.StringTokenizer; + +import com.samskivert.net.HttpPostUtil; +import com.samskivert.util.Logger; +import com.samskivert.util.ServiceWaiter; +import com.samskivert.util.StringUtil; + +/** + * Utility methods to verify that a user is actually a Shockwave Gameblast + * member. + * + * See GameBlastRemoteAPI.htm in the docs. + */ +public class BlastVerify +{ + /** Return status code indicating that the account verified. */ + public static final byte VERIFIED = 0; + + /** Return status code indicating invalid account. */ + public static final byte INVALID_ACCOUNT = 1; + + /** Return status code indicating an expired account. */ + public static final byte EXPIRED = 2; + + /** Return status code indicating that there were technical difficulties + * and the blastiness of the user cannot be determined at this time. */ + public static final byte RETRY = 3; + + /** Returns status code indicating invalid account. */ + public static final byte INVALID_PASSWORD = 4; + + /** + * Verify the username/password to ensure that it's an active shockwave + * blast user. + * + * @return true if the account is in good standing, false if it doesn + */ + public static byte verifyBlastUser (String username, String password) + { + String request = "member_name=" + StringUtil.encode(username) + + "&password=" + StringUtil.encode(password); + + String response; + try { + response = HttpPostUtil.httpPost(LOGIN_URL, request, TIMEOUT); + + } catch (ServiceWaiter.TimeoutException te) { + return RETRY; + + } catch (IOException ioe) { + log.warning("Error communicating with blast", ioe); + return RETRY; + } + + // we just do a very simplistic parsing of the response + StringTokenizer st = new StringTokenizer(response); + while (st.hasMoreTokens()) { + String s = st.nextToken(); + if (s.startsWith("Error_Code")) { + StringTokenizer st2 = new StringTokenizer(s, " =\""); + if (st2.countTokens() == 2) { + st2.nextToken(); + String code = st2.nextToken(); + try { + int result = Integer.parseInt(code); + switch (result) { + case 1: return VERIFIED; + + case 2: return INVALID_PASSWORD; + + case 3: return EXPIRED; + + case 4: return INVALID_ACCOUNT; + + default: return RETRY; + } + + } catch (NumberFormatException nfe) { + // handled below + break; + } + } else { + break; + } + } + } + + log.warning("Unable to parse response from blast [resp=\"" + response + "\"]."); + return RETRY; + } + + /** How long we wait while trying to verify. */ + protected static final int TIMEOUT = 30; + + /** The URL for username/password verification. */ + protected static URL LOGIN_URL; + + /** Used for logging. */ + protected static final Logger log = Logger.getLogger(BlastVerify.class); + + static { + try { + LOGIN_URL = new URL( + // "http://gameblastbeta.shockwave.com" + // testing URL + "https://transactor.shockwave.com" + // real URL + "/servlet/LoginRemote"); + + } catch (MalformedURLException mue) { + log.warning("Bad URL specification", mue); + } + } +} diff --git a/src/main/java/com/threerings/user/ConversionRecord.java b/src/main/java/com/threerings/user/ConversionRecord.java new file mode 100644 index 0000000..e57113f --- /dev/null +++ b/src/main/java/com/threerings/user/ConversionRecord.java @@ -0,0 +1,62 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Timestamp; + +import com.samskivert.util.StringUtil; + +/** + * Tracks conversion related actions taken by our users. + */ +public class ConversionRecord +{ + /** Indicates that an account was created. */ + public static final int CREATED = 1; + + /** Indicates that an account that was not currently paying us started + * to. This won't be true before 2005/03/01 where we tried (more or + * less) to save everytime someone gave us money. */ + public static final int SUBSCRIPTION_START = 2; + + /** Indicates that a subscription was canceled or lapsed from use. */ + public static final int SUBSCRIPTION_ENDED = 3; + + /** The unique identifier of the user that took an action. */ + public int userId; + + /** + * The partner with whom the user is associated. You should always + * use the accessor method to access this value in order to correctly + * deal with personal referrals. + */ + public int siteId; + + /** The identifier indicating the action taken. */ + public short action; + + /** The date on which the action was taken. */ + public Timestamp recorded; + + /** + * Returns the site id associated with this record, properly mapping + * negative ids (personal referrals) into a single category. + */ + public int getSiteId () + { + return (siteId <= 0) ? OOOUser.REFERRAL_SITE_ID : siteId; + } + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } + + public boolean equals (ConversionRecord cr) + { + return (cr.userId == userId && cr.siteId == siteId && + cr.action == action && cr.recorded.equals(recorded)); + } +} diff --git a/src/main/java/com/threerings/user/ConversionRepository.java b/src/main/java/com/threerings/user/ConversionRepository.java new file mode 100644 index 0000000..948e2a4 --- /dev/null +++ b/src/main/java/com/threerings/user/ConversionRepository.java @@ -0,0 +1,292 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; + +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.Maps; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.JORARepository; +import com.samskivert.jdbc.jora.Table; + +import com.samskivert.util.ArrayIntSet; +import com.samskivert.util.HashIntMap; +import com.samskivert.util.IntIntMap; +import com.samskivert.util.Calendars; + +import static com.threerings.user.Log.log; + +/** + * Provides an interface to the conversion repository. A service that + * makes use of the OOO user management and billing system can make use of + * this conversion service to track conversion related information for + * their users. + */ +public class ConversionRepository extends JORARepository +{ + /** + * The database identifier used when establishing a database + * connection. This value being conversiondb. + */ + public static final String CONVERSION_DB_IDENT = "conversiondb"; + + /** + * Creates the repository and prepares it for operation. + * + * @param provider the database connection provider. + */ + public ConversionRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, CONVERSION_DB_IDENT); + + // figure out whether or not the forums are in use on this system + execute(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + _active = JDBCUtil.tableExists(conn, "CONVERSION"); + if (!_active) { + log.info("No conversion table. Disabling."); + } + return null; + } + }); + + // TEMP CODE To update the repository to using a full integer for + // siteId, as personal referrer ids are -userIds which can be + // quite large~ + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + if (!_active) { + return null; + } + if (JDBCUtil.getColumnType(conn, "CONVERSION", "SITE_ID") == Types.SMALLINT) { + JDBCUtil.changeColumn(conn, "CONVERSION", "SITE_ID", + "SITE_ID INTEGER NOT NULL"); + } + return null; + } + }); + } + + /** + * Records an action in the conversion table. + */ + public void recordAction (int userId, int siteId, int action) + throws PersistenceException + { + recordAction(userId, siteId, action, new Timestamp(System.currentTimeMillis())); + } + + /** + * Records an action in the conversion table. + */ + public void recordAction (int userId, int siteId, int action, Timestamp recorded) + throws PersistenceException + { + if (!_active) { + return; + } + + ConversionRecord record = new ConversionRecord(); + record.userId = userId; + record.siteId = siteId; + record.action = (short)action; + record.recorded = recorded; + insert(_ctable, record); + } + + /** + * Get the subscriber info for a given date broken up by site. Key 0 + * references a total for all sites. + */ + public IntIntMap getSiteSubscribers (java.util.Date date) + { + checkRecomputeSubInfo(); + return _subscribers.get(date); + } + + /** + * Return the total number of subscribers on then given date. + */ + public int getTotalSubscribers (java.util.Date date) + { + checkRecomputeSubInfo(); + return _subscribers.get(date).get(0); + } + + /** + * Returns all of our subscription info. Date -> IntIntMap, + * IntIntMap: SiteId -> Subscribers. SiteId 0 is a total of all sites. + */ + public Map getSubscriptionInfo () + { + checkRecomputeSubInfo(); + return _subscribers; + } + + /** + * Recompute our subscription info if it is older than the cache time. + */ + protected void checkRecomputeSubInfo () + { + if (_subTime + SUB_CACHE_TIME < System.currentTimeMillis()) { + try { + computeSubscriptionInfo(); + } catch (PersistenceException pe) { + log.warning("Failed to compute our subscription info: " + pe); + } + } + } + + /** + * Build up all the subscriber data over time and affiliate. + */ + protected void computeSubscriptionInfo () + throws PersistenceException + { + // get all the raw data from the db, ordered by date + List data = loadAll(_ctable, "where ACTION in (" + + ConversionRecord.SUBSCRIPTION_START + ", " + + ConversionRecord.SUBSCRIPTION_ENDED + ") order by RECORDED"); + + java.util.Date recent = null; + + ArrayIntSet subs = new ArrayIntSet(); + HashIntMap siteSubs = new HashIntMap(); + + // these are used to do the right thing if we get both a subscribe + // and unsubscribe action in the same day, but in the wrong order + ArrayIntSet subexcept = new ArrayIntSet(); + ArrayIntSet unsubexcept = new ArrayIntSet(); + + Iterator itr = data.iterator(); + while (itr.hasNext()) { + ConversionRecord cr = itr.next(); + + // we only care about the date, not the time, at which the + // entry was recorded since we are hashing by day + java.util.Date day = Calendars.at(cr.recorded).zeroTime().toDate(); + + // if the date changes, store the data for the most recent day + if (recent == null) { + recent = day; + } else if (!recent.equals(day)) { + subexcept.clear(); + unsubexcept.clear(); + addSubscriptionEntry(recent, subs, siteSubs); + recent = day; + } + + // make sure we have a sitewise set of subscribers + if (!siteSubs.containsKey(cr.getSiteId())) { + siteSubs.put(cr.getSiteId(), new ArrayIntSet()); + } + ArrayIntSet siteMap = siteSubs.get(cr.getSiteId()); + + // update our data + if (cr.action == ConversionRecord.SUBSCRIPTION_START) { + // handle not-subscribed -> unsubscribe -> subscribe + if (!checkExcept(unsubexcept, subexcept, + subs.contains(cr.userId), cr)) { + subs.add(cr.userId); + siteMap.add(cr.userId); + } + + } else if (cr.action == ConversionRecord.SUBSCRIPTION_ENDED) { + // handle is-subscribed -> subscribe -> unsubscribe + if (!checkExcept(subexcept, unsubexcept, + !subs.contains(cr.userId), cr)) { + subs.remove(cr.userId); + siteMap.remove(cr.userId); + } + } + } + + // add in the incomplete current day info + addSubscriptionEntry(Calendars.now().zeroTime().toDate(), subs, siteSubs); + + // and mark when we finished + _subTime = System.currentTimeMillis(); + } + + protected boolean checkExcept (ArrayIntSet expect, ArrayIntSet surprise, + boolean currentStatus, ConversionRecord cr) + { + // first check to see if he's in the opposite exception set; in + // which case we clear him out and ignore this action + if (expect.remove(cr.userId)) { +// Log.info("Processed exception [rec=" + cr + +// ", status=" + currentStatus + "]."); + return true; + } + + // next, check to see if he's already in the expected state, in which + // case we put him into the exception set + return currentStatus; + } + + /** + * Add an entry to the _subscriptions data for the given day. + */ + protected void addSubscriptionEntry (java.util.Date day, ArrayIntSet subs, + HashIntMap siteSubs) + { + IntIntMap daysubs = new IntIntMap(); + for (Map.Entry entry : siteSubs.entrySet()) { + ArrayIntSet set = entry.getValue(); + int key = entry.getKey().intValue(); + daysubs.put(key, set.size()); + } + daysubs.put(0, subs.size()); + _subscribers.put(day, daysubs); + } + + @Override + protected void createTables () + { + _ctable = new Table( + // this isn't the real primary key, but we never load conversion + // records individually + ConversionRecord.class, "CONVERSION", "RECORDED", true); + } + + /** + * Contains subscription info about our users over time. Keys are + * dates and the values are IntIntMaps mapping siteId -> subsrivers + * with 0 holding the total for all sites. + */ + protected Map _subscribers = Maps.newHashMap(); + + /** The time at which we last computed our subscriber info. */ + protected long _subTime; + + /** The time in millis to cache our subscriber info before recomputing. */ + protected long SUB_CACHE_TIME = 1l * 60l * 60l * 1000l; + + /** If we don't detect our tables, we deactivate ourselves. */ + protected boolean _active; + + /** The table used to new billing actions that have taken place. */ + protected Table _ctable; + + /** The date (3/17/2005 in ms) after which we should complain about + wacky conversion data. */ + protected static long FIXED_DATE = 1111090429312L; +} diff --git a/src/main/java/com/threerings/user/DetailedUser.java b/src/main/java/com/threerings/user/DetailedUser.java new file mode 100644 index 0000000..5af9fb8 --- /dev/null +++ b/src/main/java/com/threerings/user/DetailedUser.java @@ -0,0 +1,34 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +/** + * Used to load detailed information about a user from the OOO user + * database tables. + */ +public class DetailedUser +{ + /** The user's assigned integer userid. */ + public int userId; + + /** The user's chosen username. */ + public String username; + + /** The date this record was created. */ + public Date created; + + /** The user's email address. */ + public String email; + + /** The user's birthday. */ + public Date birthday; + + /** The user's gender. */ + public byte gender; + + /** The missive provided by the user during registration. */ + public String missive; +} diff --git a/src/main/java/com/threerings/user/ExternalAuther.java b/src/main/java/com/threerings/user/ExternalAuther.java new file mode 100644 index 0000000..cffe0cb --- /dev/null +++ b/src/main/java/com/threerings/user/ExternalAuther.java @@ -0,0 +1,66 @@ +// +// $Id$ + +package com.threerings.user; + +import com.samskivert.util.ByteEnum; +import com.samskivert.util.StringUtil; + +/** + * Represents external authentication sources supported by OOOuser. + */ +public enum ExternalAuther + implements ByteEnum +{ + FACEBOOK(1, "facebook.com"), + OPEN_SOCIAL(2, "opensocial.com"), + OPEN_ID(3, "openid.net"), + HEYZAP(4, "heyzap.com") { + @Override public String makeEmail (String externalId) { + return super.makeEmail(StringUtil.encode(externalId)); + } + @Override public String getExternalId (String email) { + return StringUtil.decode(super.getExternalId(email)); + } + }, + STEAM(5, "steampowered.com"), + SEGA_PASS(6, "sega.com"), + KONGREGATE(7, "kongregate.com"); + + /** + * Creates a fake email address given the supplied external user id. + */ + public String makeEmail (String externalId) + { + return externalId + "@" + _domain; + } + + /** + * Returns the external id associated with the given email for this auther + */ + public String getExternalId (String email) + { + int idx = email.indexOf("@"); + if (idx <= 0) { + return email; + } + return email.substring(0, idx); + } + + // from ByteEnum + public byte toByte () + { + return _code; + } + + ExternalAuther (int code, String domain) { + if (code < 1 || code > 31) { // these must fit in an int + throw new IllegalArgumentException("Code out of byte range: " + code); + } + _code = (byte)code; + _domain = domain; + } + + protected byte _code; + protected String _domain; +} diff --git a/src/main/java/com/threerings/user/GameBlastAuxRepository.java b/src/main/java/com/threerings/user/GameBlastAuxRepository.java new file mode 100644 index 0000000..55747f5 --- /dev/null +++ b/src/main/java/com/threerings/user/GameBlastAuxRepository.java @@ -0,0 +1,164 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import com.samskivert.io.PersistenceException; + +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.JORARepository; + +/** + * Stores gameblast aux data for the X users that actually need it, where + * X is a small and annoying number. + */ +public class GameBlastAuxRepository extends JORARepository +{ + /** + * Constructs a new GameBlastAuxRepository. + */ + public GameBlastAuxRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, OOOUserRepository.USER_REPOSITORY_IDENT); + } + + @Override + protected void createTables () + { + // nada + } + + /** + * @return the username/password for the specified userId, or null + * if none exists. + */ + public String[] getAuxData (final int userId) + throws PersistenceException + { + return execute(new Operation() { + public String[] invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = conn.createStatement(); + try { + String query = "select LOGIN, PASSWORD from " + + "GAMEBLAST_AUX where USER_ID = " + userId; + + ResultSet rs = stmt.executeQuery(query); + if (rs.next()) { + return new String[] { rs.getString(1), + rs.getString(2) }; + } + return null; // didn't find + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Ensure that the specified gameblast login is is not present or if it is, + * only belongs to the specified user. + */ + public boolean ensureLoginUnique (final String login, final int userId) + throws PersistenceException + { + Boolean boolval = execute(new Operation() { + public Boolean invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement("select USER_ID from " + + "GAMEBLAST_AUX where LOGIN=?"); + stmt.setString(1, login); + ResultSet rs = stmt.executeQuery(); + + // return true if there are no matches or if the first + // one is us. (There should never be two, anyway). + return Boolean.valueOf( + !rs.next() || (rs.getInt(1) == userId)); + } finally { + JDBCUtil.close(stmt); + } + } + }); + return boolval.booleanValue(); + } + + /** + * Save the data for the specified user. + */ + public void saveAuxData (final int userId, + final String login, final String password) + throws PersistenceException + { + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + PreparedStatement stmt = null; + try { + // try updating first + stmt = conn.prepareStatement( + "update GAMEBLAST_AUX set " + + "LOGIN=?, PASSWORD=? where USER_ID=?"); + stmt.setString(1, login); + stmt.setString(2, password); + stmt.setInt(3, userId); + if (stmt.executeUpdate() != 1) { + // insert a new one + PreparedStatement stmt2 = null; + try { + stmt2 = conn.prepareStatement( + "insert into GAMEBLAST_AUX " + + "(USER_ID, LOGIN, PASSWORD) values (?, ?, ?)"); + stmt2.setInt(1, userId); + stmt2.setString(2, login); + stmt2.setString(3, password); + JDBCUtil.checkedUpdate(stmt2, 1); + } finally { + JDBCUtil.close(stmt2); + } + } + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Remove the gameblast mapping for the specified user. + */ + public void removeAuxData (final int userId) + throws PersistenceException + { + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = conn.createStatement(); + try { + String query = "delete from GAMEBLAST_AUX where USER_ID=" + + userId; + stmt.executeUpdate(query); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } +} diff --git a/src/main/java/com/threerings/user/HistoricalUser.java b/src/main/java/com/threerings/user/HistoricalUser.java new file mode 100644 index 0000000..8a57eff --- /dev/null +++ b/src/main/java/com/threerings/user/HistoricalUser.java @@ -0,0 +1,24 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +/** + * Retains information about a historical user registration. + */ +public class HistoricalUser +{ + /** The user's assigned integer userid. */ + public int userId; + + /** The user's chosen username. */ + public String username; + + /** The date this record was created. */ + public Date created; + + /** The affiliate site with which this user is associated. */ + public int siteId; +} diff --git a/src/main/java/com/threerings/user/Log.java b/src/main/java/com/threerings/user/Log.java new file mode 100644 index 0000000..b05f7f5 --- /dev/null +++ b/src/main/java/com/threerings/user/Log.java @@ -0,0 +1,15 @@ +// +// $Id$ + +package com.threerings.user; + +import com.samskivert.util.Logger; + +/** + * Contains a reference to the log object used by the user package. + */ +public class Log +{ + /** The log instance that will be used to log all messages for the user package. */ + public static Logger log = Logger.getLogger("com.threerings.user"); +} diff --git a/src/main/java/com/threerings/user/LoginThrottle.java b/src/main/java/com/threerings/user/LoginThrottle.java new file mode 100644 index 0000000..0e208d8 --- /dev/null +++ b/src/main/java/com/threerings/user/LoginThrottle.java @@ -0,0 +1,106 @@ +// +// $Id$ + +package com.threerings.user; + +import com.google.common.collect.HashMultiset; +import com.google.common.collect.Multiset; + +import com.samskivert.util.Interval; + +import static com.threerings.user.Log.log; + +/** + * Tracks logins by a user identifier and if they attempt to login too often, lets us know. + * Note that the period is how often we clear the entire table, so specific users may find + * up to 2x the attempt rate during their first set of attempts if it happens to cross the + * boundary. + */ +public class LoginThrottle +{ + public LoginThrottle (int maxLogins, long period) + { + _maxLogins = maxLogins; + + (new Interval() { + @Override + public void expired () + { + synchronized(_recentLogins) { + _recentLogins.clear(); + } + } + }).schedule(period, true); + } + + /** + * Notes that the user attempted to login. + * @return Whether they're allowed to continue based on throttle settings. + */ + @Deprecated + public boolean noteLogin (K userIdentifier) + { + synchronized(_recentLogins) { + int loginCount = 1 + _recentLogins.add(userIdentifier, 1); + if (loginCount > _maxLogins) { + recordThrottledAttempt(userIdentifier, loginCount); + return false; + } else { + return true; + } + } + } + + /** + * Notes that the user successfully logged in (so reduces their count by 1). + */ + @Deprecated + public void noteLoginSuccess (K userIdentifier) + { + synchronized(_recentLogins) { + _recentLogins.add(userIdentifier, -1); + } + } + + /** + * Should we block a login attempt because the user has already failed too many times? + */ + public boolean isLoginAttemptBlocked (K userIdentifier) + { + int count; + synchronized (_recentLogins) { + count = _recentLogins.count(userIdentifier); + } + if (_maxLogins <= count) { + recordThrottledAttempt(userIdentifier, count); + return true; + } + return false; + } + + /** + * Note a failed login, and return true if it's time to tell them that we'll block + * them for a little while. + */ + public boolean noteFailedLogin (K userIdentifier) + { + synchronized (_recentLogins) { + return _maxLogins <= 1 + _recentLogins.add(userIdentifier, 1); + } + } + + /** + * Log that we had a failed attempt. Can be overridden if you really don't care about tracking + * that sort of thing. + */ + protected void recordThrottledAttempt (K userIdentifier, int loginCount) + { + log.info("Throttled login attempt", "identifier", userIdentifier, "loginCount", loginCount); + } + + /** How many logins they're allowed to try during a period. */ + protected int _maxLogins; + + /** Recent login attempt counts by user identifier. */ + protected Multiset _recentLogins = HashMultiset.create(); +} diff --git a/src/main/java/com/threerings/user/OOOAuxData.java b/src/main/java/com/threerings/user/OOOAuxData.java new file mode 100644 index 0000000..2f2c1aa --- /dev/null +++ b/src/main/java/com/threerings/user/OOOAuxData.java @@ -0,0 +1,32 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +import com.samskivert.util.StringUtil; + +/** + * Auxiliary information relating to our registered users. + */ +public class OOOAuxData +{ + /** The user's unique identifier. */ + public int userId; + + /** The user's birthday. */ + public Date birthday; + + /** The user's gender. */ + public byte gender; + + /** The user's personal missive to us. */ + public String missive; + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/OOOBillAuxData.java b/src/main/java/com/threerings/user/OOOBillAuxData.java new file mode 100644 index 0000000..970e81e --- /dev/null +++ b/src/main/java/com/threerings/user/OOOBillAuxData.java @@ -0,0 +1,22 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Timestamp; + +/** + * Contains additional information on users that have paid us in one form or + * another. + */ +public class OOOBillAuxData +{ + /** The user's unique identifier. */ + public int userId; + + /** The first time the user bought coins **/ + public Timestamp firstCoinBuy; + + /** The most recent time the user bought coins **/ + public Timestamp latestCoinBuy; +} diff --git a/src/main/java/com/threerings/user/OOOSite.java b/src/main/java/com/threerings/user/OOOSite.java new file mode 100644 index 0000000..51e0aec --- /dev/null +++ b/src/main/java/com/threerings/user/OOOSite.java @@ -0,0 +1,21 @@ +// +// $Id$ + +package com.threerings.user; + +import com.samskivert.servlet.Site; + +/** + * Used to identify OOO sites. + */ +public class OOOSite extends Site +{ + /** The domain at which this site is found, e.g. puzzlepirates.com */ + public final String domain; + + public OOOSite (int siteId, String siteString, String domain) + { + super(siteId, siteString); + this.domain = domain; + } +} diff --git a/src/main/java/com/threerings/user/OOOSiteIdentifier.java b/src/main/java/com/threerings/user/OOOSiteIdentifier.java new file mode 100644 index 0000000..c674db8 --- /dev/null +++ b/src/main/java/com/threerings/user/OOOSiteIdentifier.java @@ -0,0 +1,127 @@ +// +// $Id$ + +package com.threerings.user; + +import java.util.Iterator; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import com.google.common.collect.Lists; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.servlet.JDBCTableSiteIdentifier; +import com.samskivert.servlet.Site; +import com.samskivert.servlet.util.CookieUtil; +import com.samskivert.util.StringUtil; + +import static com.threerings.user.Log.log; + +/** + * Handles the identification of OOO sites without requiring us to maintain a domains everywhere we + * use our webapps. It still uses the "sites" table for affiliate handling, so we can set up + * affiliates using the register webapp and whatnot, but it uses a static mapping (defined in + * OOOUser) for our domains and site ids. + */ +public class OOOSiteIdentifier extends JDBCTableSiteIdentifier +{ + /** A request property that can be used to override the domain-based site identification. See + * {@link HttpServletRequest#setAttribute}. */ + public static final String SITE_ID_OVERRIDE_KEY = "SiteIdentifierOverride"; + + /** A cookie that can be provided with a request to override the domain-based site + * identification. This is superceded by {@link #SITE_ID_OVERRIDE_KEY}. */ + public static final String SITE_COOKIE = "site"; + + public OOOSiteIdentifier (ConnectionProvider conprov) + throws PersistenceException + { + super(conprov); + } + + @Override // from JDBCTableSiteIdentifier + public int identifySite (HttpServletRequest req) + { + // look for request parameter, accept it as an override and store for the session + String requestParam = req.getParameter(SITE_ID_OVERRIDE_KEY); + if (requestParam != null) { + try { + Integer siteId = Integer.parseInt(requestParam); + req.getSession().setAttribute(SITE_ID_OVERRIDE_KEY, siteId); + return siteId; + } catch (Exception e) { + log.warning("Received invalid site override param", "site", requestParam); + // fall through to other methods + } + } + + // check for override in the request attributes and store in the session + Integer attributeOverride = (Integer)req.getAttribute(SITE_ID_OVERRIDE_KEY); + if (attributeOverride != null) { + req.getSession().setAttribute(SITE_ID_OVERRIDE_KEY, attributeOverride); + return attributeOverride; + } + + // check whether our site id was overridden during this session + Integer override = (Integer)req.getSession().getAttribute(SITE_ID_OVERRIDE_KEY); + if (override != null) { + return override; + } + + // check for a site cookie + String sitecookie = CookieUtil.getCookieValue(req, SITE_COOKIE); + if (!StringUtil.isBlank(sitecookie)) { + try { + return Integer.parseInt(sitecookie); + } catch (Exception e) { + log.warning("Received invalid site cookie", "site", sitecookie); + // fall through to the domain parsing + } + } + + // otherwise we just use a static mapping + String serverName = req.getServerName(); + for (OOOSite site : OOOUser.SITES) { + if (serverName.endsWith(site.domain)) { + return site.siteId; + } + } + return super.identifySite(req); + } + + @Override // from JDBCTableSiteIdentifier + public String getSiteString (int siteId) + { + for (OOOSite site : OOOUser.SITES) { + if (site.siteId == siteId) { + return site.siteString; + } + } + return super.getSiteString(siteId); + } + + @Override // from JDBCTableSiteIdentifier + public int getSiteId (String siteString) + { + for (OOOSite site : OOOUser.SITES) { + if (site.siteString.equals(siteString)) { + return site.siteId; + } + } + return super.getSiteId(siteString); + } + + @Override // from JDBCTableSiteIdentifier + public Iterator enumerateSites () + { + List sites = Lists.newArrayList(super.enumerateSites()); + for (OOOSite site : OOOUser.SITES) { + if (!_sitesById.containsKey(site.siteId)) { + sites.add(site); + } + } + return sites.iterator(); + } +} diff --git a/src/main/java/com/threerings/user/OOOUser.java b/src/main/java/com/threerings/user/OOOUser.java new file mode 100644 index 0000000..d165196 --- /dev/null +++ b/src/main/java/com/threerings/user/OOOUser.java @@ -0,0 +1,617 @@ +// +// $Id$ + +package com.threerings.user; + +import java.util.Iterator; +import java.util.List; +import java.util.StringTokenizer; + +import com.google.common.collect.ImmutableList; + +import com.samskivert.jdbc.jora.FieldMask; +import com.samskivert.servlet.user.User; +import com.samskivert.util.ArrayIntSet; + +import static com.threerings.user.Log.log; + +/** + * Extends the basic samskivert user record with special Three Rings business. + */ +public class OOOUser extends User +{ + /** The flag set when the user's e-mail address has been validated. */ + public static final int VALIDATED_FLAG = (1 << 0); + + /** A flag set when a user has opted-in to receive partner site spam. */ + public static final int AFFILIATE_SPAM_FLAG = (1 << 1); + + /** A flag set when a user pays us money to buy coins. */ + public static final int HAS_BOUGHT_COINS_FLAG = (1 << 2); + + /** A flag set when a user redeems a ubisoft cd key. */ + public static final int UBISOFT_KEY_REDEEMED_FLAG = (1 << 3); + + /** A flag set when a user pays us money for bulk time. */ + public static final int HAS_BOUGHT_TIME_FLAG = (1 << 4); + + /** Indicates that a user is an active player of Y!PP. */ + public static final int IS_ACTIVE_YOHOHO_PLAYER = (1 << 5); + + /** Indicates that a user is an active player of B!H. */ + public static final int IS_ACTIVE_BANG_PLAYER = (1 << 6); + + /** Indicates that a user is an active player of GG. */ + public static final int IS_ACTIVE_GARDENS_PLAYER = (1 << 7); + + /** Indicates that the user has an active subscription on the family friendly servers */ + public static final int FAMILY_SUBSCRIBER = (1 << 8); + + /** Indicates that the user has been involved in a conversion to a Steam account (either as the + * source or the destination of the conversion). */ + public static final int CONVERTED_TO_STEAM = (1 << 9); + + /** An access token indicating that this user is an admin. */ + public static final byte ADMIN = 1; + + /** An access token indicating that this user is a maintainer. */ + public static final byte MAINTAINER = 2; + + /** An access token indicating that this user is an insider. */ + public static final byte INSIDER = 3; + + /** An access token indicating that this user is a tester. */ + public static final byte TESTER = 4; + + /** An access token indicating that this user is banned from Puzzle Pirates. */ + public static final byte PP_BANNED = 5; + + /** An access token indicating that this user is customer support personnel. */ + public static final byte SUPPORT = 6; + + /** An access token allowing them to spend more than the default max. */ + public static final byte BIG_SPENDER = 7; + + /** An access token indicating that the user has bounced a check or reversed a payment for + * Puzzle Pirates. */ + public static final byte PP_DEADBEAT = 8; + + /** An access token indicating that this user is banned from Bang! Howdy. */ + public static final byte BANG_BANNED = 9; + + /** An access token indicating that the user has bounced a check or reversed a payment for + * Bang! Howdy. */ + public static final byte BANG_DEADBEAT = 10; + + /** An access token indicating that this user is banned from MetaSOY. */ + public static final byte MSOY_BANNED = 11; + + /** An access token indicating that the user has bounced a check or reversed a payment for + * MetaSOY. */ + public static final byte MSOY_DEADBEAT = 12; + + /** An access token indicating that this user is banned from an app. */ + public static final byte APPS_BANNED = 13; + + /** An access token indicating that the user has bounced a check or reversed a payment for an + * app. */ + public static final byte APPS_DEADBEAT = 14; + + /** An access token indicating that the user is banned from Project X. */ + public static final byte PROJECTX_BANNED = 15; + + /** An access token indicating that the user has bounced a check or reversed a payment for + * Project X. */ + public static final byte PROJECTX_DEADBEAT = 16; + + /** Billing status flags for a particular service. */ + public static final byte TRIAL_STATE = 0; + public static final byte SUBSCRIBER_STATE = 1; + public static final byte BILLING_FAILURE_STATE = 2; + public static final byte EX_SUBSCRIBER_STATE = 3; + public static final byte BANNED_STATE = 4; + + /** A regular expression that defines the valid characters for affiliate site identifier + * strings. */ + public static final String SITE_STRING_REGEX = "[-._A-Za-z0-9]+"; + + /** The default site id to use in the absence of others; currently puzzlepirates.com. + * Eventually we'll have to have something on a per-domain basis. */ + public static final int DEFAULT_SITE_ID = 2; + + /** The puzzlepirates.com site identifier. */ + public static final int PUZZLEPIRATES_SITE_ID = 2; + + /** affiliate site id used for "alt" accounts created through pp.com/register/. */ + public static final int PUZZLEPIRATES_ALT_SITE_ID = 164; + + /** The gamegardens.com site identifier. */ + public static final int GAMEGARDENS_SITE_ID = 6; + + /** The site id we group personal referrers under when doing reporting. This should never be + * inserted into the database. */ + public static final int REFERRAL_SITE_ID = 7; + + /** The banghowdy.com site identifier. */ + public static final int BANGHOWDY_SITE_ID = 8; + + /** The whirled.com site identifier. */ + public static final int METASOY_SITE_ID = 9; + + /** The puzzlepiratesfamily.com site identifier */ + public static final int YPPFAMILY_SITE_ID = 10; + + /** The apps.threerings.net site identifier. */ + public static final int APPS_SITE_ID = 11; + + /** The Everything app site identifier. */ + public static final int EVERYTHING_SITE_ID = 12; + + /** The BiteMe app site identifier. */ + public static final int BITEME_SITE_ID = 13; + + /** The Down Town app site identifier. */ + public static final int DOWNTOWN_SITE_ID = 14; + + /** The Face Pirate app site identifier. */ + public static final int FACEPIRATE_SITE_ID = 15; + + /** The ProjectX app site identifier - not yet used for billing, only register. */ + public static final int PROJECTX_SITE_ID = 204; + + /** The Who site identifier - jumping to 1000 to avoid collisions with YPP and SK affiliates */ + public static final int WHO_SITE_ID = 1000; + + /** The Ubisoft site id, which we need for various hackery. */ + public static final int UBISOFT_SITE_ID = 40; + + /** A mapping from domain to site id for the OOO sites. */ + public static List SITES = ImmutableList.of( + new OOOSite(PUZZLEPIRATES_SITE_ID, "puzzlepirates", "puzzlepirates.com"), + new OOOSite(GAMEGARDENS_SITE_ID, "gardens", "gamegardens.com"), + new OOOSite(BANGHOWDY_SITE_ID, "bang", "banghowdy.com"), + new OOOSite(METASOY_SITE_ID, "metasoy", "whirled.com"), + new OOOSite(YPPFAMILY_SITE_ID, "family", "puzzlepiratesfamily.com"), + new OOOSite(APPS_SITE_ID, "apps", "apps.threerings.net"), + new OOOSite(EVERYTHING_SITE_ID, "everything", "notused"), + new OOOSite(BITEME_SITE_ID, "biteme", "notused"), + new OOOSite(DOWNTOWN_SITE_ID, "downtown", "notused"), + new OOOSite(PROJECTX_SITE_ID, "projectx", "spiralknights.com")); + + /** The subscriber column name for Puzzle Pirates subscribers. Used by various repository + * methods. */ + public static final String PUZZLEPIRATES_COLUMN = "yohoho"; + + /** Used to make sure someone doesn't do something stupid. */ + public static final String[] IDENTS_NOT_LOADED = {}; + + /** The flags detailing the user's various bits of status. (VALIDATED_FLAG, etc) */ + public int flags; + + /** The tokens detailing the user's site access permissions. (ADMIN, TESTER, etc) */ + public byte[] tokens; + + /** The user's account status for Yohoho! Puzzle Pirates. (TRIAL_STATE, SUBSCRIBER_STATE, + * etc) */ + public byte yohoho; + + /** The spots that have been given to the user by various crews. */ + public String spots; + + /** The amount of time remaining on the users shun, in minutes. */ + public int shunLeft; + + /** The id of any opaque tag provided by the affiliate to tag this user for their purposes. */ + public int affiliateTagId; + + /** The list of machine identifiers associated with this user. */ + public transient String[] machIdents = IDENTS_NOT_LOADED; + + /** + * Returns the banned token for the site or 0 if an invalid site. + */ + public static byte getBannedToken (int site) + { + switch (site) { + case YPPFAMILY_SITE_ID: + case PUZZLEPIRATES_SITE_ID: return PP_BANNED; + case BANGHOWDY_SITE_ID: return BANG_BANNED; + case METASOY_SITE_ID: return MSOY_BANNED; + case EVERYTHING_SITE_ID: return APPS_BANNED; + case BITEME_SITE_ID: return APPS_BANNED; + case DOWNTOWN_SITE_ID: return APPS_BANNED; + case FACEPIRATE_SITE_ID: return APPS_BANNED; + case PROJECTX_SITE_ID: return PROJECTX_BANNED; + default: return (byte)0; // no other sites currently support banning + } + } + + /** + * Returns the deadbeat token for the site or 0 if an unsupported site. + */ + public static byte getDeadbeatToken (int site) + { + switch (site) { + case YPPFAMILY_SITE_ID: + case PUZZLEPIRATES_SITE_ID: return PP_DEADBEAT; + case BANGHOWDY_SITE_ID: return BANG_DEADBEAT; + case METASOY_SITE_ID: return MSOY_DEADBEAT; + case EVERYTHING_SITE_ID: return APPS_DEADBEAT; + case BITEME_SITE_ID: return APPS_DEADBEAT; + case DOWNTOWN_SITE_ID: return APPS_DEADBEAT; + case FACEPIRATE_SITE_ID: return APPS_DEADBEAT; + case PROJECTX_SITE_ID: return PROJECTX_DEADBEAT; + default: + log.warning("Requested deadbeat token for unsupported site", "site", site); + return (byte)0; + } + } + + /** + * Returns whether the user's e-mail address has been validated. + */ + public boolean isValidated () + { + return isFlagSet(VALIDATED_FLAG); + } + + /** + * Returns true if the user has even purchased coins from us. + */ + public boolean hasBoughtCoins () + { + return isFlagSet(HAS_BOUGHT_COINS_FLAG); + } + + /** + * Returns true if the user has even purchased time from us. + */ + public boolean hasBoughtTime () + { + return isFlagSet(HAS_BOUGHT_TIME_FLAG); + } + + /** + * @return true if the specified flag has been set. + */ + public boolean isFlagSet (int flag) + { + return ((flags & flag) != 0); + } + + /** + * Updates the user's e-mail validation status. + */ + public void setValidated (boolean validated) + { + setFlag(VALIDATED_FLAG, validated); + } + + /** + * Checks whether the user is flagged as a family ocean subscriber. + */ + public boolean isFamilySubscriber () + { + return isFlagSet(OOOUser.FAMILY_SUBSCRIBER); + } + + /** + * Set or clear the specified flag. + */ + public void setFlag (int flag, boolean set) + { + if (set) { + this.flags |= flag; + } else { + this.flags &= ~flag; + } + setModified("flags"); + } + + /** + * Adds the supplied access token to this user's token ring. + */ + public void addToken (byte token) + { + // check to see if they already have it + if (!holdsToken(token)) { + if (tokens == null) { + tokens = new byte[] { token }; + } else { + int tcount = tokens.length; + byte[] ntokens = new byte[tcount+1]; + System.arraycopy(tokens, 0, ntokens, 0, tcount); + ntokens[tcount] = token; + tokens = ntokens; + } + setModified("tokens"); + } + } + + /** + * Set all the spots that this user has recieved. + */ + public void setSpots (ArrayIntSet blackspots) + { + if (blackspots == null) { + throw new IllegalArgumentException("Blackspots parameter can not be null"); + } + String newspots = ""; + Iterator itr = blackspots.iterator(); + for (int ii = 0; itr.hasNext(); ii++) { + Integer crewid = itr.next(); + if (ii == 0) { + newspots += crewid; + } else { + newspots += ":" + crewid; + } + } + spots = newspots; + + setModified("spots"); + } + + /** + * Converts the String representation of the users black spots to an ArrayIntSet. Each spot is, + * in fact, the crewid of the crew that gave it to them. + */ + public ArrayIntSet getSpots () + { + ArrayIntSet blackSpots = new ArrayIntSet(); + if (spots == null) { + return blackSpots; + } + + StringTokenizer tok = new StringTokenizer(spots, ":"); + + while (tok.hasMoreTokens()) { + String crewid = tok.nextToken(); + try { + int spot = Integer.parseInt(crewid); + blackSpots.add(spot); + } catch (NumberFormatException nfe) { + log.warning("Failed parsing spots.", + "user", username, "crewid", crewid, "excpetion", nfe); + } + } + + return blackSpots; + } + + /** + * Removes the supplied access token from this user's token ring. + */ + public void removeToken (byte token) + { + // make sure they actually have it + if (holdsToken(token)) { + // the tokens array is likely to always be very small, so we + // don't go to the trouble of trying to do this with arraycopy + int tcount = tokens.length; + byte[] ntokens = new byte[tcount-1]; + for (int ii = 0, npos = 0; ii < tcount; ii++) { + if (tokens[ii] == token) { + continue; + } + ntokens[npos++] = tokens[ii]; + } + tokens = ntokens; + setModified("tokens"); + } + } + + /** + * Returns true if this user holds the specified token. + */ + public boolean holdsToken (byte token) + { + if (tokens == null) { + return false; + } + + int tcount = tokens.length; + for (int ii = 0; ii < tcount; ii++) { + if (tokens[ii] == token) { + return true; + } + } + + return false; + } + + /** + * Set the billing status of the user for a particular site. + * + * @return true if the status changed, false if not. + */ + public boolean setBillingStatus (int site, byte status) + { + switch (site) { + case METASOY_SITE_ID: + // we maintain a separate OOOUSER installation for msoy and thus rather than adding + // another column to maintain our subscriber status for msoy, we just reuse yohoho's + case PUZZLEPIRATES_SITE_ID: + case YPPFAMILY_SITE_ID: + if (yohoho != status) { + yohoho = status; + setModified("yohoho"); + return true; + } + break; + default: + throw new IllegalArgumentException( + "Tried to set billing status for unknown site [site=" + site + "]."); + } + return false; + } + + /** + * Return the billing status for the passed in site. + */ + public byte getBillingStatus (int site) + { + switch (site) { + case METASOY_SITE_ID: // see setBillingStatus + case PUZZLEPIRATES_SITE_ID: + return yohoho; + case YPPFAMILY_SITE_ID: + return isFamilySubscriber() ? SUBSCRIBER_STATE : yohoho; + default: + return TRIAL_STATE; + } + } + + public boolean isMaintainer () + { + return holdsToken(MAINTAINER); + } + + @Override + public boolean isAdmin () + { + return holdsToken(ADMIN) || isMaintainer(); + } + + /** + * Returns true if this user is an "insider" (and as such, should be allowed in for free) + */ + public boolean isInsider () + { + return (holdsToken(INSIDER) || isAdmin()); + } + + /** + * Returns true if this user holds the support token + */ + public boolean isSupport () + { + return holdsToken(SUPPORT); + } + + /** + * Returns true if this user holds the support token (or higher) + */ + public boolean isSupportPlus () + { + return isSupport() || isAdmin(); + } + + /** + * Returns true if this user is subscriber to Puzzle Pirates. + */ + public boolean isSubscriber () + { + return isSubscriber(PUZZLEPIRATES_SITE_ID); + } + + public boolean isSubscriber (int site) + { + return ((getBillingStatus(site) == SUBSCRIBER_STATE) || isInsider()); + } + + /** + * Returns true if we have allowed the user to be a big spender. + */ + public boolean isBigSpender () + { + return holdsToken(BIG_SPENDER); + } + + /** + * Returns true if this user is banned. + */ + public boolean isBanned (int site) + { + byte token = getBannedToken(site); + return (token == 0 ? false : holdsToken(token)); + } + + /** + * Configures this user's banned status for the specified site. + */ + public boolean setBanned (int site, boolean banned) + { + byte token = getBannedToken(site); + if (token == 0) { + log.warning("Requested to update banned for invalid site", "site", site); + return false; + } + if (banned) { + addToken(token); + } else { + removeToken(token); + } + return true; + } + + /** + * Returns true if this user has bounced a check or reversed a payment. + */ + public boolean isDeadbeat (int site) + { + byte token = getDeadbeatToken(site); + return (token == 0 ? false : holdsToken(token)); + } + + /** + * Configures this user's deadbeat status for the specified site. + */ + public void setDeadbeat (int site, boolean deadbeat) + { + byte token = getDeadbeatToken(site); + if (token != 0) { + if (deadbeat) { + addToken(token); + } else { + removeToken(token); + } + } + } + + /** + * Returns our Yohoho! billing status. + */ + public byte getYohohoStatus () + { + return getBillingStatus(PUZZLEPIRATES_SITE_ID); + } + + /** + * Marks this user as a non paying user. + */ + public void makeTrialYohoho () + { + setBillingStatus(PUZZLEPIRATES_SITE_ID, TRIAL_STATE); + } + + /** + * Returns true if this user holds any of the specified tokens. + */ + public boolean holdsAnyToken (byte[] tokset) + { + if (tokens == null) { + return false; + } + + int tcount = tokens.length, scount = tokset.length; + for (int ii = 0; ii < tcount; ii++) { + for (int tt = 0; tt < scount; tt++) { + if (tokens[ii] == tokset[tt]) { + return true; + } + } + } + + return false; + } + + // A protected method can be called by another class in the same package, but if you extend the + // class containing the protected method and try to call that method from a class in the same + // package as the derived class, it doesn't work. However, if we simply override the method and + // do nothing but call super, it's considered "legal." Yay! + @Override + protected void setDirtyMask (FieldMask mask) + { + super.setDirtyMask(mask); + } +} diff --git a/src/main/java/com/threerings/user/OOOUserCard.java b/src/main/java/com/threerings/user/OOOUserCard.java new file mode 100644 index 0000000..b1fcf0f --- /dev/null +++ b/src/main/java/com/threerings/user/OOOUserCard.java @@ -0,0 +1,47 @@ +// +// $Id$ + +package com.threerings.user; + +import com.google.common.base.Function; + +/** + * Identifying information and flags for a {@link OOOUser} object. This is for use when querying + * a potentially large number of users meeting some criteria and only each user's name and flags + * are required. + */ +public class OOOUserCard +{ + /** Converts a card to a username. */ + public static Function TO_USERNAME = new Function() { + public String apply (OOOUserCard card) { + return card.username; + } + }; + + /** + * Creates a new user card with the given field values. + */ + public OOOUserCard (int userid, String username, int flags) + { + this.userid = userid; + this.username = username; + this.flags = flags; + } + + /** + * Creates a new user card for deserialization. + */ + public OOOUserCard () + { + } + + /** The unique id of the user. */ + public int userid; + + /** The name of the user. */ + public String username; + + /** The {@link OOOUser#flags} of the user. */ + public int flags; +} diff --git a/src/main/java/com/threerings/user/OOOUserManager.java b/src/main/java/com/threerings/user/OOOUserManager.java new file mode 100644 index 0000000..3bb5aff --- /dev/null +++ b/src/main/java/com/threerings/user/OOOUserManager.java @@ -0,0 +1,186 @@ +// +// $Id$ + +package com.threerings.user; + +import java.util.Map; +import java.util.Properties; +import javax.servlet.http.HttpServletRequest; + +import com.google.common.collect.Maps; + +import com.samskivert.io.PersistenceException; +import com.samskivert.util.RunQueue; +import com.samskivert.jdbc.ConnectionProvider; + +import com.samskivert.servlet.RedirectException; + +import com.samskivert.servlet.user.User; +import com.samskivert.servlet.user.UserManager; +import com.samskivert.servlet.user.UserRepository; + +import static com.threerings.user.Log.log; + +/** + * Extends the standard user manager with OOO-specific support. + */ +public class OOOUserManager extends UserManager +{ + /** + * Creates our OOO User manager and prepares it for operation. + */ + public OOOUserManager (Properties config, ConnectionProvider conprov) + throws PersistenceException + { + this(config, conprov, null); + } + + /** + * Creates our OOO user manager and prepares it for operation. + */ + public OOOUserManager (Properties config, ConnectionProvider conprov, RunQueue pruneQueue) + throws PersistenceException + { + // legacy business + init(config, conprov, pruneQueue); + } + + /** + * Creates an OOO user manager which must subsequently be initialized with + * a call to {@link #init}. + */ + public OOOUserManager () + { + } + + @Override + public OOOUserRepository getRepository () + { + return (OOOUserRepository)_repository; + } + + @Override + public void init (Properties config, ConnectionProvider conprov, RunQueue pruneQueue) + throws PersistenceException + { + super.init(config, conprov, pruneQueue); + + // create the blast repository + _blastRepo = new GameBlastAuxRepository(conprov); + + // look up the access denied URL + _accessDeniedURL = config.getProperty("access_denied_url"); + if (_accessDeniedURL == null) { + log.warning("No 'access_denied_url' supplied in user manager config. " + + "Restricted pages will behave strangely."); + } + + // load up our affiliate tag mappings + for (AffiliateTag sub : getRepository().loadAffiliateTags()) { + _tagMap.put(sub.tag, sub.tagId); + } + } + + /** + * Get the gameblast aux data repository. + */ + public GameBlastAuxRepository getBlastRepository () + { + return _blastRepo; + } + + /** + * Returns the id to which the specified tag has been mapped, assigning a new tag id if + * necessary. + * + * @return -1 if an error occurred assigning a new id. + */ + public int getAffiliateTagId (String tag) + { + // if we've already mapped this value, we're good to go + Integer tagId = _tagMap.get(tag); + if (tagId != null) { + return tagId; + } + + // register a new affiliate tag and add it to our mappings + try { + tagId = getRepository().registerAffiliateTag(tag); + _tagMap.put(tag, tagId); + return tagId; + + } catch (PersistenceException pe) { + log.warning("Failed to register new affiliate tag '" + tag + "'.", pe); + return -1; + } + } + + /** + * Both tag strings and ids are unique, so grab the string (key) based on the id (value) + * @param tagId ID to search for (eg 45) + * @return String value of the tag or tags (eg "nov07GroupA::DF34A9") + */ + public String getAffiliateTagString (int tagId) + { + Integer theTagId = Integer.valueOf(tagId); + + for (Map.Entry entry : _tagMap.entrySet()) { + if (theTagId.equals(entry.getValue())) { + return entry.getKey(); + } + } + return null; + } + + /** + * Extends the standard {@link #requireUser(HttpServletRequest)} with + * the additional requirement that the user hold the specified token. + * If they do not, they will be redirected to a page informing them + * access is denied. + * + * @return the user associated with the request. + */ + public User requireUser (HttpServletRequest req, byte token) + throws PersistenceException, RedirectException + { + OOOUser user = (OOOUser)requireUser(req); + if (!user.holdsToken(token)) { + throw new RedirectException(_accessDeniedURL); + } + return user; + } + + /** + * Extends the standard {@link #requireUser(HttpServletRequest)} with + * the additional requirement that the user hold one of the specified + * tokens. If they do not, they will be redirected to a page informing + * them access is denied. + * + * @return the user associated with the request. + */ + public User requireUser (HttpServletRequest req, byte[] tokens) + throws PersistenceException, RedirectException + { + OOOUser user = (OOOUser)requireUser(req); + if (!user.holdsAnyToken(tokens)) { + throw new RedirectException(_accessDeniedURL); + } + return user; + } + + @Override + protected UserRepository createRepository (ConnectionProvider conprov) + throws PersistenceException + { + return new OOOUserRepository(conprov); + } + + /** The repository for gameblast auxiliary data. */ + protected GameBlastAuxRepository _blastRepo; + + /** The URL to which we redirect users whose access is denied. */ + protected String _accessDeniedURL; + + /** Maintains a mapping of affiliate tags. */ + protected Map _tagMap = Maps.newHashMap(); +} diff --git a/src/main/java/com/threerings/user/OOOUserRepository.java b/src/main/java/com/threerings/user/OOOUserRepository.java new file mode 100644 index 0000000..0a3246e --- /dev/null +++ b/src/main/java/com/threerings/user/OOOUserRepository.java @@ -0,0 +1,1808 @@ +// +// $Id$ + +package com.threerings.user; + +import static com.threerings.user.Log.log; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import com.google.common.collect.Lists; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.jora.FieldMask; +import com.samskivert.jdbc.jora.Table; +import com.samskivert.servlet.user.Password; +import com.samskivert.servlet.user.User; +import com.samskivert.servlet.user.UserExistsException; +import com.samskivert.servlet.user.UserRepository; +import com.samskivert.servlet.user.Username; +import com.samskivert.util.ArrayIntSet; +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.Calendars; +import com.samskivert.util.HashIntMap; +import com.samskivert.util.IntIntMap; +import com.samskivert.util.StringUtil; +import com.samskivert.util.Tuple; + +/** + * Extends the samskivert user repository with custom Three Rings business. + */ +public class OOOUserRepository extends UserRepository +{ + /** Access granted, user is not banned nor coming from a tainted machine. */ + public static final int ACCESS_GRANTED = 0; + + /** User is trying to create a new account from a tainted machine. */ + public static final int NEW_ACCOUNT_TAINTED = 1; + + /** The user is banned from playing. */ + public static final int ACCOUNT_BANNED = 2; + + /** The user can not create another free account on this machine. */ + public static final int NO_NEW_FREE_ACCOUNT = 3; + + /** The user has bounced a check or reversed a payment. */ + public static final int DEADBEAT = 4; + + /** + * Creates the repository and opens the user database. The database identifier used to fetch + * our database connection is documented by {@link #USER_REPOSITORY_IDENT}. + * + * @param provider the database connection provider. + */ + public OOOUserRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider); + + // purge validation records that have expired + purgeValidationRecords(); + } + + /** + * Creates a new user record in the repository with no auxiliary data. + */ + public int createUser ( + Username username, Password password, String email, int siteId, int tagId) + throws UserExistsException, PersistenceException + { + return createUser(username, password, email, siteId, tagId, null, (byte)-1, null); + } + + /** + * Creates a new user record in the repository. + */ + public int createUser (Username username, Password password, String email, int siteId, + int tagId, int birthyear, byte gender, String missive) + throws UserExistsException, PersistenceException + { + // convert birth year to a fake birthday (Jan 1 of that year) + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.YEAR, birthyear); + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.MONTH, 0); + return createUser(username, password, email, siteId, tagId, + new Date(cal.getTimeInMillis()), gender, missive); + } + + /** + * Creates a new user record in the repository. + */ + public int createUser (Username username, Password password, String email, int siteId, + int tagId, Date birthday, byte gender, String missive) + throws UserExistsException, PersistenceException + { + OOOUser user = new OOOUser(); + user.setDirtyMask(_utable.getFieldMask()); + populateUser(user, username, password, email, siteId, tagId); + int userId = insertUser(user); + + // store their auxiliary data (if provided) + if (birthday != null || gender >= 0 || missive != null) { + OOOAuxData auser = new OOOAuxData(); + auser.userId = userId; + auser.birthday = birthday; + auser.gender = gender; + auser.missive = (missive == null ? "" : missive); + // if we allow this to be longer it won't fit in the database column and the insert + // will choke + auser.missive = StringUtil.truncate(auser.missive, 255); + try { + insert(_atable, auser); + } catch (PersistenceException pe) { + Throwable err = (pe.getCause() == null) ? pe : pe.getCause(); + log.warning("Failed to insert auxdata " + auser + ": " + err + "."); + } + } + + // put this user down on the permanent record + HistoricalUser huser = new HistoricalUser(); + huser.userId = userId; + huser.username = username.getUsername(); + huser.created = user.created; + huser.siteId = siteId; + insert(_htable, huser); + + return userId; + } + + /** + * Looks up a user by username and optionally loads their machine identifier information. + * + * @return the user with the specified user id or null if no user with that id exists. + */ + public OOOUser loadUser (String username, boolean loadIdents) + throws PersistenceException + { + OOOUser user = (OOOUser)loadUser(username); + if (user == null || !loadIdents) { + return user; + } + loadMachineIdents(user); + return user; + } + + /** + * Looks up a user by email address and optionally loads their machine identifier information. + * + * @return the user with the specified address or null if no such user exists. + */ + public OOOUser loadUserByEmail (String email, boolean loadIdents) + throws PersistenceException + { + OOOUser user = (OOOUser)loadUserWhere("where email = " + JDBCUtil.escape(email)); + if (user == null || !loadIdents) { + return user; + } + loadMachineIdents(user); + return user; + } + + /** + * Changes a user's username. + * + * @return true if the old username existed and was changed to the new name, false if the old + * username did not exist. + * + * @exception UserExistsException thrown if the new name is already in use. + */ + public boolean changeUsername (final int userId, final String username) + throws PersistenceException, UserExistsException + { + return executeUpdate(new Operation() { + public Boolean invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "update users set username = ? where userId = ?"; + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement(query); + stmt.setString(1, username); + stmt.setInt(2, userId); + return (stmt.executeUpdate() >= 1); + + } catch (SQLException sqe) { + if (liaison.isDuplicateRowException(sqe)) { + throw new UserExistsException("error.user_exists"); + } else { + throw sqe; + } + + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Updates the active state of the specified user for the specified site flag (e.g. {@link + * OOOUser#IS_ACTIVE_YOHOHO_PLAYER}). + */ + public void updateUserIsActive (String username, int activeFlag, boolean isActive) + throws PersistenceException + { + final String query = "update users set flags = flags " + + (isActive ? ("| " + activeFlag) : ("& ~" + activeFlag)) + + " where username=" + JDBCUtil.escape(username); + executeUpdate(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException { + Statement stmt = conn.createStatement(); + try { + stmt.executeUpdate(query); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Loads up the machine ident information for the supplied user. + */ + public void loadMachineIdents (OOOUser user) + throws PersistenceException + { + // fill in this user's known machine identifiers + List idents = Lists.newArrayList(); + String where = "where USER_ID = " + user.userId; + for (UserIdent ident : loadAll(_itable, where)) { + idents.add(ident.machIdent); + } + user.machIdents = idents.toArray(new String[idents.size()]); + // sort the idents in java to ensure correct collation + Arrays.sort(user.machIdents); + } + + /** + * Returns 0 if the supplied user id is below the current throttle user id, the number of hours + * of accounts ahead of it in the queue if it is greater. + */ + public int checkThrottle (final int userId) + throws PersistenceException + { + Integer val = execute(new Operation () { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "select MAX_USERID, INCREMENT from THROTTLE"; + Statement stmt = null; + int remain = 0; + + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + if (rs.next()) { + int maxUserId = rs.getInt(1); + int increment = rs.getInt(2); + if (maxUserId < userId) { + remain = (int)Math.ceil( + ((float)userId-maxUserId)/increment); + } + + } else { + log.warning("No throttle data!?"); + } + + } finally { + JDBCUtil.close(stmt); + } + + return Integer.valueOf(remain); + } + }); + return val.intValue(); + } + + /** + * Updates the throttle counter by adding the increment to the smaller of the maximum user id + * or the highest userid currently registered. The previous maximum user id is returned along + * with the increment and the highest userid currently registered. + */ + public int[] updateThrottle () + throws PersistenceException + { + return executeUpdate(new Operation () { + public int[] invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = null; + int[] values = new int[3]; + + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("select MAX_USERID, INCREMENT from THROTTLE"); + if (rs.next()) { + values[0] = rs.getInt(1); + values[1] = rs.getInt(2); + } else { + log.warning("No throttle data!?"); + } + rs = stmt.executeQuery("select MAX(userId) from users"); + if (rs.next()) { + values[2] = rs.getInt(1); + } else { + log.warning("No MAX(userId)!?"); + } + + // now add the increment and update; we add the increment to the lesser of the + // previous max allowed userid and the actual max userid so that we don't get + // ahead of ourselves if there's a lull + int newMax = Math.min(values[0], values[2]) + values[1]; + stmt.executeUpdate("update THROTTLE set MAX_USERID = " + newMax); + + log.info("Updated throttle [newMax=" + newMax + + ", values=" + StringUtil.toString(values) + "]."); + + } finally { + JDBCUtil.close(stmt); + } + + return values; + } + }); + } + + /** + * Returns an array of usernames registered with the specified email address or the empty array + * if none are registered with said address. + */ + public String[] getUsernames (final String email) + throws PersistenceException + { + return execute(new Operation () { + public String[] invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "select username from users where email = ?"; + List names = Lists.newArrayList(); + PreparedStatement stmt = null; + + try { + stmt = conn.prepareStatement(query); + stmt.setString(1, email); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + names.add(rs.getString(1)); + } + } finally { + JDBCUtil.close(stmt); + } + + return names.toArray(new String[names.size()]); + } + }); + } + + /** + * Returns an array of usernames from the supplied collection that have the specified token set. + */ + public List getTokenUsernames (Collection names, byte token) + throws PersistenceException + { + StringBuilder where = new StringBuilder("where username in ("); + int idx = 0; + for (String username : names) { + if (idx++ > 0) { + where.append(","); + } + where.append(JDBCUtil.escape(username)); + } + where.append(") and hex(tokens) regexp '^([0-9A-F][0-9A-F])*"); + where.append(String.format("%1$02X", (int)token)); + where.append("([0-9A-F][0-9A-F])*$'"); + return getUsernamesWhere(where.toString()); + } + + /** + * Returns an array of usernames resulting from the supplied query. Care should be taken + * when constructing these queries as the user table is likely to be large and a query that + * does not make use of indices could be very slow. + */ + public List getUsernamesWhere (final String where) + throws PersistenceException + { + return execute(new Operation> () { + public List invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "select username from users " + where; + List names = Lists.newArrayList(); + Statement stmt = null; + + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + names.add(rs.getString(1)); + } + } finally { + JDBCUtil.close(stmt); + } + + return names; + } + }); + } + + + /** + * Creates and returns a fake user record, which will not be inserted into the database, but + * which can be used to satisfy the early authentication of a "service" client; another process + * connecting to the game server process. + */ + public OOOUser createServiceUser (String username, Password password) + { + OOOUser user = new OOOUser(); + user.userId = -1; + user.username = username; + // the user record will need a dirty field mask because it will want to fiddle with it if + // any of its fields are changed... + user.setDirtyMask(_utable.getFieldMask()); + // ...like the password field + user.setPassword(password); + return user; + } + + /** + * Returns the total number of registered users and the number of users that registered in the + * last 24 hours. We love the stats. + */ + public int[] getRegiStats () + throws PersistenceException + { + return execute(new Operation () { + public int[] invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + int[] data = new int[2]; + Statement stmt = conn.createStatement(); + try { + String query = "select count(*) from history"; + ResultSet rs = stmt.executeQuery(query); + if (rs.next()) { + data[0] = rs.getInt(1); + } + + query = "select count(*) from history where created = CURRENT_DATE"; + rs = stmt.executeQuery(query); + if (rs.next()) { + data[1] = rs.getInt(1); + } + + } finally { + JDBCUtil.close(stmt); + } + + return data; + } + }); + } + + /** + * Returns the count of registrations per day for the last limit days. The + * returned tuple array contains ({@link Date}, count) pairs. + */ + public List> getRecentRegCount (final int limit) + throws PersistenceException + { + final List> list = Lists.newArrayList(); + execute(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = conn.createStatement(); + try { + String query = "select created, count(created) " + + "from history group by created desc limit " + limit; + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + list.add(new Tuple( + rs.getDate(1), Integer.valueOf(rs.getInt(2)))); + } + return null; + + } finally { + JDBCUtil.close(stmt); + } + } + }); + return list; + } + + /** + * Returns a HashSet of all the usernames who are subscribed. + */ + public HashSet getSubscriberUsernames (final String column) + throws PersistenceException + { + return execute(new Operation> () { + public HashSet invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + HashSet data = new HashSet(); + Statement stmt = conn.createStatement(); + try { + // return every username that is a subscriber + String query = "select username from users where " + column + " = " + + OOOUser.SUBSCRIBER_STATE; + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + data.add(rs.getString(1)); + } + } finally { + JDBCUtil.close(stmt); + } + + return data; + } + }); + } + + /** + * Returns a new Set that is a subset of the names in the provided collection, the new Set + * containing only usernames that have purchased coins. The original collection is not + * modified. + */ + public HashSet filterCoinBuyers (final Collection usernames) + throws PersistenceException + { + return execute(new Operation>() { + public HashSet invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + final HashSet data = new HashSet(); + JDBCUtil.BatchProcessor proc = new JDBCUtil.BatchProcessor() { + public void process (ResultSet row) throws SQLException { + data.add(row.getString(1)); + } + }; + String query = "select username from users, BILLAUXDATA " + + "where username in (#KEYS#) and BILLAUXDATA.USER_ID = " + + "users.userId and FIRST_COIN_BUY is not NULL"; + JDBCUtil.batchQuery(conn, query, usernames, true, FILTER_COIN_BATCH, proc); + return data; + } + }); + } + + /** + * Returns a new Set that is a subset of the userIds in the provided collection, the new Set + * containing only userIds that have purchased coins for the first time in the interval + * provided. The original collection is not modified. + */ + public HashSet filterNewCoinBuyers ( + final Collection userIds, final Date start, final Date end) + throws PersistenceException + { + return execute(new Operation>() { + public HashSet invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + final HashSet data = new HashSet(); + JDBCUtil.BatchProcessor proc = new JDBCUtil.BatchProcessor() { + public void process (ResultSet row) throws SQLException { + data.add(row.getInt(1)); + } + }; + String query = "select USER_ID from BILLAUXDATA where " + + "USER_ID in (#KEYS#) and FIRST_COIN_BUY >= '" + start + + "' and FIRST_COIN_BUY <= '" + end + "'"; + JDBCUtil.batchQuery(conn, query, userIds, true, FILTER_COIN_BATCH, proc); + return data; + } + }); + } + + /** + * Return a mapping of affliate ids -> count of users who registered within the specified date + * range (inclusive). + */ + public IntIntMap getAffiliateRegistrationCount (Date start, Date end) + throws PersistenceException + { + return getAffiliateCount( + "from history where created >= '" + start + "' and created <= '" + end + "'"); + } + + /** + * Return a mapping of affliate ids -> currently or once subscribed users who registered within + * the specified date range (inclusive). + * + * @param column the column that denotes subscription for the desired site (currently only + * {@link OOOUser#PUZZLEPIRATES_COLUMN}). + */ + public IntIntMap getAffiliateSubscriberCount ( + Date start, Date end, String column) + throws PersistenceException + { + return getAffiliateCount("from users where created >= '" + start + "' " + + "and created <= '" + end + "' and " + column + " != 0"); + } + + /** + * Return a mapping of affliate ids -> users that have purchased coins and who registered + * within the specified date range (inclusive). + */ + public IntIntMap getAffiliateCoinBuyerCount (Date start, Date end) + throws PersistenceException + { + return getAffiliateCount("from users where created >= '" + start + "' " + + "and created <= '" + end + "' " + + "and (flags & " + OOOUser.HAS_BOUGHT_COINS_FLAG + ") != 0"); + } + + /** + * Return a mapping of affliate ids -> users that have purchased time and who registered within + * the specified date range (inclusive). + */ + public IntIntMap getAffiliateTimeBuyerCount (Date start, Date end) + throws PersistenceException + { + return getAffiliateCount("from users where created >= '" + start + "' " + + "and created <= '" + end + "' " + + "and (flags & " + OOOUser.HAS_BOUGHT_TIME_FLAG + ") != 0"); + } + + /** + * Helper function for {@link #getAffiliateRegistrationCount} and {@link + * #getAffiliateSubscriberCount}. + */ + protected IntIntMap getAffiliateCount (String fromWhere) + throws PersistenceException + { + final String query = "select siteId, count(*) " + fromWhere + " group by siteId"; + return execute(new Operation () { + public IntIntMap invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = null; + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + + // slurp up the results + IntIntMap data = new IntIntMap(); + while (rs.next()) { + int siteId = rs.getInt(1); + int count = rs.getInt(2); + if (siteId < 0 || siteId == OOOUser.REFERRAL_SITE_ID) { + data.increment(OOOUser.REFERRAL_SITE_ID, count); + } else { + data.put(siteId, count); + } + } + return data; + + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Get the number of created user accounts in the specified date range. Returns the count by + * day using a LinkedHashMap (Date => Count) to preserve the date ordering returned by the + * database. + */ + public Map getAffiliateRegistrationCounts (Date start, Date end, int siteId) + throws PersistenceException + { + return getAffiliateCounts("from history where siteId = " + siteId + + " and created >= '" + start + "' and created <= '" + end + "'"); + } + + /** + * Get the number of subscriber user accounts created in the specified date range. Returns the + * count by day using a LinkedHashMap (Date => Count) to preserve the date ordering returned by + * the database. + * + * @param siteId (aka affiliateId) the affiliate site to look up. + * @param column the column that denotes subscription for the desired site (currently only + * {@link OOOUser#PUZZLEPIRATES_COLUMN}). + */ + public Map getAffiliateSubscriberCounts ( + Date start, Date end, int siteId, String column) + throws PersistenceException + { + return getAffiliateCounts("from users where siteId = " + siteId + + " and created >= '" + start + "' and created <= '" + end + "'" + + " and " + column + " != " + OOOUser.TRIAL_STATE); + } + + /** + * Get the number of coin-buying user accounts created in the specified date range. Returns + * the count by day using a LinkedHashMap (Date => Count) to preserve the date ordering + * returned by the database. + */ + public Map getAffiliateCoinBuyerCounts (Date start, Date end, int siteId) + throws PersistenceException + { + return getAffiliateCounts("from users where siteId = " + siteId + + " and created >= '" + start + "' and created <= '" + end + "'" + + " and (flags & " + OOOUser.HAS_BOUGHT_COINS_FLAG + ") != 0"); + } + + /** + * Get the number of coin-buying user accounts created in the specified date range. + * + * @return the count by day using a LinkedHashMap (Date => Count) to preserve the date ordering + * returned by the database. + */ + public Map getAffiliateTimeBuyerCounts (Date start, Date end, int siteId) + throws PersistenceException + { + return getAffiliateCounts("from users where siteId = " + siteId + + " and created >= '" + start + "' and created <= '" + end + "'" + + " and (flags & " + OOOUser.HAS_BOUGHT_TIME_FLAG + ") != 0"); + } + + /** + * Loads all subaffiliate mappings. + */ + public List loadAffiliateTags () + throws PersistenceException + { + return loadAll(_tagtable, ""); + } + + /** + * Adds a new affiliate tag mapping and returns the assigned identifier. + */ + public int registerAffiliateTag (String tag) + throws PersistenceException + { + final AffiliateTag record = new AffiliateTag(); + record.tag = tag; + + return executeUpdate(new Operation() { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + try { + // first try inserting a new record + _tagtable.insert(conn, record); + return liaison.lastInsertedId(conn, _tagtable.getName(), "tagId"); + + } catch (SQLException sqe) { + // if someone else has already added this mapping, look it up + if (liaison.isDuplicateRowException(sqe)) { + AffiliateTag erecord = _tagtable.select( + conn, "where TAG = " + JDBCUtil.escape(record.tag)).get(); + if (erecord != null) { + return erecord.tagId; + } + log.warning("AffiliateTag table inconsistency " + record + ": " + sqe); + // fall through and rethrow the exception + } + throw sqe; + } + } + }); + } + + /** + * Helper function for {@link #getAffiliateRegistrationCounts} and {@link + * #getAffiliateSubscriberCounts}. + */ + protected Map getAffiliateCounts (String fromWhere) + throws PersistenceException + { + final String query = "select created, count(*) " + fromWhere + + " group by created order by created desc"; + return execute(new Operation> () { + public Map invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = null; + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + + // slurp up the results + Map results = new LinkedHashMap(); + while (rs.next()) { + results.put(rs.getDate(1), Integer.valueOf(rs.getInt(2))); + } + return results; + + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Returns a mapping from affiliate id to integer array of registrations for that affiliate on + * each day in the specified range. + */ + public HashIntMap getAffiliateRegistrationHistory (final Date start, final Date end) + throws PersistenceException + { + return execute(new Operation> () { + public HashIntMap invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "select created, siteId, count(userId) " + + "from history where created >= ? and created <= ? and siteId > 0 " + + "group by created, siteId order by created"; + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement(query); + stmt.setDate(1, start); + stmt.setDate(2, end); + ResultSet rs = stmt.executeQuery(); + + // count up the number of days in total + Calendar d1 = Calendar.getInstance(); + d1.setTime(start); + Calendar d2 = Calendar.getInstance(); + d2.setTime(end); + int days = Calendars.getDaysBetween(d1, d2) + 1; + + HashIntMap data = new HashIntMap(); + Date rowdate = start; + int didx = 0; + while (rs.next()) { + Date created = rs.getDate(1); + int siteId = rs.getInt(2); + int registrations = rs.getInt(3); + + // advance the date if necessary + if (!created.equals(rowdate)) { + d2.setTime(created); + didx = Calendars.getDaysBetween(d1, d2); + rowdate = created; + } + + // create an array for this site if necessary + int[] values = data.get(siteId); + if (values == null) { + data.put(siteId, values = new int[days]); + } + values[didx] = registrations; + } + + return data; + + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Returns a list of detail records for the users registered in our database, starting with the + * specified record number and containing at most count elements. + * + * @param filter if true, unvalidated users and users that are already testers or the like are + * filtered out. + */ + public List getDetailRecords (final int start, final int count, final boolean filter) + throws PersistenceException + { + return execute(new Operation> () { + public List invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "where users.userId = AUXDATA.USER_ID "; + if (filter) { + query += "AND users.flags != 0 AND users.tokens = '' "; + } + query += "ORDER BY userId DESC LIMIT " + start + ", " + count; + // look up the user + return _dtable.join(conn, "AUXDATA", query).toArrayList(); + } + }); + } + + /** + * Returns a list of all detail records that match the specified search string in their + * realname, username or email address. + */ + public List searchDetailRecords (final String term) + throws PersistenceException + { + return execute(new Operation> () { + public List invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String newquery = "left join AUXDATA " + + "on userId = USER_ID where username like '%" + term + + "%' union select userId, username, created, " + + "email, birthday, gender, missive " + + "from users left join AUXDATA " + + "on userId = USER_ID where email like '%" + term + + "%' order by userId desc"; + return _dtable.select(conn, newquery).toArrayList(); + } + }); + } + + /** + * Returns a list of all detail records that exactly match the specified email. + */ + public List searchDetailRecordsByEmail (final String email) + throws PersistenceException + { + return execute(new Operation> () { + public List invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String newquery = "left join AUXDATA on userId = USER_ID " + + "where email = '" + email + "' order by userId desc"; + return _dtable.select(conn, newquery).toArrayList(); + } + }); + } + + /** + * Creates a pending record for an account that has been created but not yet validated (which + * involves the user accessing a secret URL we send to them in an email message). If a record + * already exists for the supplied user, it will be reused and returned. + */ + public ValidateRecord createValidateRecord (int userId, boolean persist) + throws PersistenceException + { + // reuse an existing record if we have one + ValidateRecord rec = getValidateRecord(userId); + if (rec != null) { + return rec; + } + + // otherwise create a new one and insert it into the database + rec = new ValidateRecord(); + rec.secret = Long.toString(Math.abs((new Random()).nextLong()), 16); + rec.userId = userId; + rec.persist = persist; + rec.inserted = new Date(System.currentTimeMillis()); + insert(_vtable, rec); + return rec; + } + + /** + * Fetches the validate record matching the specified secret and removes it from the pending + * validations table. + */ + public ValidateRecord getValidateRecord (final String secret) + throws PersistenceException + { + ValidateRecord proto = new ValidateRecord(); + proto.secret = secret; + ValidateRecord vrec = loadByExample(_vtable, proto); + if (vrec != null) { + // delete this record from yon table + delete(_vtable, vrec); + } + return vrec; + } + + /** + * Fetches the validate record for the specifed user. + */ + public ValidateRecord getValidateRecord (final int userId) + throws PersistenceException + { + return load(_vtable, "where userId = '" + userId + "'"); + } + + /** + * Purges expired validation records from the table. + */ + public void purgeValidationRecords () + throws PersistenceException + { + update("delete from penders where inserted < DATE_SUB(CURDATE(), INTERVAL 1 MONTH)"); + } + + /** + * Loads up the aux data record for the specified user. Returns null if none exists for that id. + */ + public OOOAuxData getAuxRecord (final int userId) + throws PersistenceException + { + return load(_atable, "where USER_ID = '" + userId + "'"); + } + + /** + * Loads up the billing aux data record for the specified user. Returns a blank record (with + * userId filled in) if none exists for that id. + */ + public OOOBillAuxData getBillAuxData (int userId) + throws PersistenceException + { + OOOBillAuxData baux = load(_batable, "where USER_ID = '" + userId + "'"); + if (baux == null) { + baux = new OOOBillAuxData(); + baux.userId = userId; + } + return baux; + } + + /** + * Updates the supplied record in the database, creating the record if necessary. + */ + public void updateBillAuxData (OOOBillAuxData record) + throws PersistenceException + { + if (update(_batable, record) == 0) { + insert(_batable, record); + } + } + + /** + * Checks whether or not the specified machine identifier should be allowed to create a new + * account. + * + * @return {@link #ACCESS_GRANTED} if all is well and they can proceed to create their account, + * {@link #NEW_ACCOUNT_TAINTED} if this is a tainted machine ident, {@link + * #NO_NEW_FREE_ACCOUNT} if this machine ident has used all of its available free accounts. + * @deprecated Use checkCanCreate(machIdent, siteId) instead. + */ + @Deprecated + public int checkCanCreate (String machIdent) + throws PersistenceException + { + return checkCanCreate(machIdent, -1); + } + + /** + * Checks whether or not the specified machine identifier should be allowed to create a new + * account. + * + * @return {@link #ACCESS_GRANTED} if all is well and they can proceed to create their account, + * {@link #NEW_ACCOUNT_TAINTED} if this is a tainted or banned machine ident, {@link + * #NO_NEW_FREE_ACCOUNT} if this machine ident has used all of its available free accounts. + */ + public int checkCanCreate (String machIdent, int siteId) + throws PersistenceException + { + // make sure the machine identifier is not tainted or banned + if (isTaintedIdent(machIdent) || (siteId != -1 && isBannedIdent(machIdent, siteId))) { + return NEW_ACCOUNT_TAINTED; + } + + // check to see if this ident has exhausted all its free accounts + if (playedRecentFreeAccounts(machIdent, RECENT_ACCOUNT_CUTOFF, siteId) > + MAX_FREE_ACCOUNTS_PER_MACHINE) { + return NO_NEW_FREE_ACCOUNT; + } + + return ACCESS_GRANTED; + } + + /** + * Checks whether or not the user in question should be allowed access. + * + * @param site the site for which we are validating the user. + * @param newPlayer true if the user is attempting to create a new game account. + * + * @return {@link #ACCESS_GRANTED} if the account should be allowed access, {@link + * #NEW_ACCOUNT_TAINTED} if this is the account's first session and they are logging in with a + * tainted machine ident, {@link #ACCOUNT_BANNED} if this account is banned, {@link #DEADBEAT} + * if this account needs to resolve an outstanding debt. + */ + public int validateUser (int site, OOOUser user, String machIdent, boolean newPlayer) + throws PersistenceException + { + // if this user's idents were not loaded, complain + if (user.machIdents == OOOUser.IDENTS_NOT_LOADED) { + log.warning("Requested to validate user with unloaded idents " + + "[who=" + user.username + "].", new Exception()); + // err on the side of not screwing our customers + return ACCESS_GRANTED; + } + + // if we have never seen them before... + if (user.machIdents == null) { + // add their ident to the userobject and db + user.machIdents = new String[] { machIdent }; + addUserIdent(user.userId, machIdent); + + } else if (Arrays.binarySearch(user.machIdents, machIdent) < 0) { + // add the machIdent to the users list of associated idents + user.machIdents = ArrayUtil.append(user.machIdents, machIdent); + // and slap it in the db + addUserIdent(user.userId, machIdent); + } + + // if this is a banned user, mark that ident + if (user.isBanned(site)) { + addTaintedIdent(machIdent); + return ACCOUNT_BANNED; + } + + // if this is a banned machIdent just return banned status + if (isBannedIdent(machIdent, site)) { + return ACCOUNT_BANNED; + } + + // don't let those bastards grief us. + if (newPlayer && (isTaintedIdent(machIdent)) ) { + return NEW_ACCOUNT_TAINTED; + } + + // if the user has bounced a check or reversed payment, let them know + if (user.isDeadbeat(site)) { + return DEADBEAT; + } + + // if they have 0 sessions and they're not a subscriber, then make sure there aren't too + // many other free accounts already created with this machine ident + if (!user.isSubscriber() && !user.hasBoughtCoins() && newPlayer && + (playedRecentFreeAccounts(machIdent, RECENT_ACCOUNT_CUTOFF, site) > + MAX_FREE_ACCOUNTS_PER_MACHINE)) { + return NO_NEW_FREE_ACCOUNT; + } + + // you're all clear kid... + return ACCESS_GRANTED; + } + + /** + * Checks whether or not the machine in question should be allowed access. + * + * @param newPlayer true if the user is attempting to create a new game + * account. + * + * @return {@link #ACCESS_GRANTED} if the account should be allowed + * access, {@link #NEW_ACCOUNT_TAINTED} if this is the account's first + * session and they are logging in with a tainted machine ident, + * {@link #ACCOUNT_BANNED} if this account is banned, + * {@link #DEADBEAT} if this account needs to resolve an outstanding debt. + * @deprecated Use checkCanCreate(machIdent, siteId) instead. + */ + @Deprecated + public int validateMachIdent (String machIdent, boolean newPlayer) + throws PersistenceException + { + return validateMachIdent(machIdent, newPlayer, -1); + } + + /** + * Checks whether or not the machine in question should be allowed access. + * + * @param newPlayer true if the user is attempting to create a new game + * account. + * @param site the site we're trying to validate this machine on + * + * @return {@link #ACCESS_GRANTED} if the account should be allowed + * access, {@link #NEW_ACCOUNT_TAINTED} if this is the account's first + * session and they are logging in with a tainted machine ident, + * {@link #ACCOUNT_BANNED} if this account is banned, + * {@link #DEADBEAT} if this account needs to resolve an outstanding debt. + */ + public int validateMachIdent (String machIdent, boolean newPlayer, int site) + throws PersistenceException + { + // don't let those bastards grief us. + if ((newPlayer && (isTaintedIdent(machIdent))) || + (site != -1 && isBannedIdent(machIdent, site))) { + return NEW_ACCOUNT_TAINTED; + } + + // if they have 0 sessions and they're not a subscriber, then make sure + // there aren't too many other free accounts already created with this + // machine ident + if (newPlayer && (playedRecentFreeAccounts(machIdent, RECENT_ACCOUNT_CUTOFF, site) > + MAX_FREE_ACCOUNTS_PER_MACHINE)) { + return NO_NEW_FREE_ACCOUNT; + } + + // you're all clear kid... + return ACCESS_GRANTED; + } + + /** + * Returns the number of free accounts that have been played at least + * once from this machine ident and were created vaguely recently. + * Returns the number of free accounts that have been played at least once from this machine + * ident and were created vaguely recently. + */ + protected int playedRecentFreeAccounts ( + final String machIdent, final int daysInThePast, final int site) + throws PersistenceException + { + Integer bah = execute(new Operation () { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + Statement stmt = conn.createStatement(); + try { + // Compute the cutoff day for when we no longer consider an account as 'recent' + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_MONTH, daysInThePast); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + + StringBuilder query = + new StringBuilder("select count(*) from users, USER_IDENTS "); + query.append("where users.userId = USER_IDENTS.USER_ID and "); + query.append("users.username not like '%=%' and "); + query.append("USER_IDENTS.MACH_IDENT = '").append(machIdent).append("' and "); + if (site == OOOUser.PUZZLEPIRATES_SITE_ID) { + query.append("yohoho = ").append(OOOUser.TRIAL_STATE).append(" and "); + } + query.append("flags & ").append(OOOUser.HAS_BOUGHT_COINS_FLAG).append(" = 0 "); + query.append("and created > '").append(sdf.format(cal.getTime())).append("'"); + + ResultSet rs = stmt.executeQuery(query.toString()); + if (rs.next()) { + return Integer.valueOf(rs.getInt(1)); + } + return Integer.valueOf(0); + } finally { + JDBCUtil.close(stmt); + } + } + }); + + return bah.intValue(); + } + + /** + * Returns a list of all users that have ever reported the specified machine identifier. + */ + public List> getUsersOfMachIdent (String machIdent) + throws PersistenceException + { + return getUsersByIdent("MACH_IDENT = '" + machIdent + "'"); + } + + /** + * Returns a list of all users that have ever reported any of the specified machine + * identifiers. + */ + public List> getUsersOfMachIdents (String[] idents) + throws PersistenceException + { + String idstr = StringUtil.join(idents, "', '"); + if (StringUtil.isBlank(idstr)) { + return new ArrayList>(); + } else { + return getUsersByIdent("MACH_IDENT in ('" + idstr + "')"); + } + } + + /** + * Mark this user's account as banned, update the tainted machine idents table as needed. + * + * @return true if the user exists and was banned, false if not. + */ + public boolean ban (int site, String username) + throws PersistenceException + { + OOOUser user = loadUser(username, true); + if (user == null) { + return false; + } + + if (!user.setBanned(site, true)) { + return false; + } + updateUser(user); + + for (String machIdent : user.machIdents) { + addTaintedIdent(machIdent); + } + return true; + } + + /** + * Remove the ban from the users account, optionally untainting his machine idents. + * + * @return true if the user exists and was unbanned, false if not. + */ + public boolean unban (int site, String username, boolean untaint) + throws PersistenceException + { + OOOUser user = loadUser(username, true); + if (user == null) { + return false; + } + + if (!user.setBanned(site, false)) { + return false; + } + updateUser(user); + + if (untaint) { + for (String machIdent : user.machIdents) { + removeTaintedIdent(machIdent); + } + } + return true; + } + + /** + * Taints all the machine idents belonging to the specified user. + */ + public boolean taint (String username) + throws PersistenceException + { + OOOUser user = loadUser(username, true); + if (user == null) { + return false; + } + + for (String machIdent : user.machIdents) { + addTaintedIdent(machIdent); + } + return true; + } + + /** + * Checks to see if the specified machine identifier is tainted. + */ + public boolean isTaintedIdent (final String machIdent) + throws PersistenceException + { + return load(_ttable, "WHERE MACH_IDENT = '" + machIdent + "'") != null; + } + + /** + * Returns the subsect of the supplied machine idents that are tainted. + */ + public Collection filterTaintedIdents (String[] idents) + throws PersistenceException + { + List tainted = Lists.newArrayList(); + if (idents != null && idents.length > 0) { + String ids = JDBCUtil.escape(idents); + for (TaintedIdent ident : loadAll(_ttable, "where MACH_IDENT in (" + ids + ")")) { + tainted.add(ident.machIdent); + } + } + return tainted; + } + + /** + * Store to the database that the passed in machIdent has been tainted by a banned player. + */ + public void addTaintedIdent (final String machIdent) + throws PersistenceException + { + executeUpdate(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + try { + TaintedIdent id = new TaintedIdent(); + id.machIdent = machIdent; + _ttable.insert(conn, id); + } catch (SQLException e) { + if (liaison.isDuplicateRowException(e)) { + // the ident is already tainted, so don't worry about it + } else { + throw e; + } + } + return null; + } + }); + } + + /** + * Remove a machine ident from the tainted table. + */ + public void removeTaintedIdent (String machIdent) + throws PersistenceException + { + TaintedIdent id = new TaintedIdent(); + id.machIdent = machIdent; + delete(_ttable, id); + } + + /** + * Checks to see if the specified machine identifier is banned for this site. + */ + public boolean isBannedIdent (final String machIdent, final int siteId) + throws PersistenceException + { + return load(_btable, "where SITE_ID = " + siteId + " and MACH_IDENT = '" + machIdent + "'") + != null; + } + + /** + * Returns the subsect of the supplied machine idents that are banned. + */ + public Collection filterBannedIdents (String[] idents, int siteId) + throws PersistenceException + { + List banned = Lists.newArrayList(); + if (idents != null && idents.length > 0) { + String ids = JDBCUtil.escape(idents); + for (BannedIdent ident : loadAll(_btable, + "where SITE_ID = " + siteId + " and MACH_IDENT in (" + ids + ")")) { + banned.add(ident.machIdent); + } + } + return banned; + } + + /** + * Store to the database that the passed in machIdent is banned for the siteId. + */ + public void addBannedIdent (final String machIdent, final int siteId) + throws PersistenceException + { + executeUpdate(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + try { + BannedIdent id = new BannedIdent(); + id.machIdent = machIdent; + id.siteId = siteId; + _btable.insert(conn, id); + } catch (SQLException e) { + if (liaison.isDuplicateRowException(e)) { + // the ident is already banned, so don't worry about it + } else { + throw e; + } + } + return null; + } + }); + } + + /** + * Remove a machine ident from the banned table. + */ + public void removeBannedIdent (String machIdent, int siteId) + throws PersistenceException + { + BannedIdent id = new BannedIdent(); + id.machIdent = machIdent; + id.siteId = siteId; + delete(_btable, id); + } + + /** + * Add the userId -> machIdent mapping to the database. + */ + public void addUserIdent (int userId, String machIdent) + throws PersistenceException + { + UserIdent id = new UserIdent(); + id.userId = userId; + id.machIdent = machIdent; + insert(_itable, id); + } + + /** + * Set the time remaining on a particular users shun in the database. + */ + public void setUserShun (final int userId, final long shunLeft) + throws PersistenceException + { + // fake up a user with the minimal info needed to update + OOOUser minUser = new OOOUser(); + minUser.userId = userId; + minUser.shunLeft = (int)shunLeft/MILLIS_PER_MINUTE; + FieldMask mask = _utable.getFieldMask(); + mask.setModified("shunLeft"); + update(_utable, minUser, mask); + } + + /** + * Set the users black spots in the database. + */ + public void setSpots (final int userId, final ArrayIntSet spots) + throws PersistenceException + { + // fake up a user with the minimal info needed to update + OOOUser minUser = new OOOUser(); + minUser.userId = userId; + FieldMask mask = _utable.getFieldMask(); + minUser.setDirtyMask(mask); // give it the mask so setSpots works + minUser.setSpots(spots); + update(_utable, minUser, mask); + } + + /** + * Prunes users that have never bought coins or become a Yohoho subscriber that are the + * specified number of days old. + * + * @return the number of pruned accounts. + */ + public int pruneUsers (int daysOld) + throws PersistenceException + { + Calendar when = Calendar.getInstance(); + when.add(Calendar.DATE, -1 * daysOld); + Date since = new Date(when.getTimeInMillis()); + + // if any of these flags are active, we won't prune the user + int flags = (OOOUser.HAS_BOUGHT_COINS_FLAG | OOOUser.HAS_BOUGHT_TIME_FLAG | + OOOUser.IS_ACTIVE_YOHOHO_PLAYER | OOOUser.IS_ACTIVE_BANG_PLAYER | + OOOUser.IS_ACTIVE_GARDENS_PLAYER); + + // first delete the main user records + String query = "delete from users where yohoho = " + OOOUser.TRIAL_STATE + + " and flags & " + flags + " = 0 and tokens = '' and created < '" + since + "'"; + int deleted = update(query); + + // now delete orphaned records from AUXDATA and USER_IDENTS + update("delete AUXDATA from AUXDATA left join users ON " + + "userId = USER_ID where userId is null"); + update("delete USER_IDENTS from USER_IDENTS left join users ON " + + "userId = USER_ID where userId is null"); + + return deleted; + } + + /** + * Used to record email addresses of people interested in some new product that we're launching. + */ + public void recordInterestedParty (final String product, final String email) + throws PersistenceException + { + executeUpdate(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException { + String query = "insert into INTERESTED_PARTIES " + + "(PRODUCT, EMAIL_ADDRESS, RECORDED) values(?, ?, ?)"; + PreparedStatement stmt = conn.prepareStatement(query); + stmt.setString(1, product); + stmt.setString(2, email); + stmt.setTimestamp(3, new Timestamp(System.currentTimeMillis())); + try { + stmt.executeUpdate(); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + /** + * Returns a list of all users that have ever reported any of the specified machine idents. + */ + protected List> getUsersByIdent (String where) + throws PersistenceException + { + final String query = "select userId, username from users, USER_IDENTS " + + "where users.userId = USER_IDENTS.USER_ID and " + where; + return execute(new Operation>> () { + public List> invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + List> info = Lists.newArrayList(); + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + while (rs.next()) { + info.add(new Tuple( + Integer.valueOf(rs.getInt(1)), rs.getString(2))); + } + return info; + + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + @Override + protected void migrateSchema (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String[] usersSchema = { + "userId INTEGER(10) PRIMARY KEY NOT NULL AUTO_INCREMENT", + "username VARCHAR(255) NOT NULL", + "password VARCHAR(32) NOT NULL", + "email VARCHAR(128) NOT NULL", + "realname VARCHAR(128) NOT NULL", + "created DATE NOT NULL", + "siteId INTEGER NOT NULL", + "flags INTEGER NOT NULL", + "tokens TINYBLOB NOT NULL", + "yohoho TINYINT UNSIGNED NOT NULL", + "spots VARCHAR(128) NOT NULL", + "shunLeft INTEGER NOT NULL", + "affiliateTagId INTEGER NOT NULL", + "UNIQUE INDEX username_index (username)", + "INDEX email_index (email)", + }; + JDBCUtil.createTableIfMissing(conn, "users", usersSchema, ""); + + String[] historySchema = { + "userId INTEGER(10) PRIMARY KEY NOT NULL", + "username VARCHAR(255) NOT NULL", + "created DATE NOT NULL", + "siteId INTEGER NOT NULL", + "KEY(created)", + "KEY(siteId)", + }; + JDBCUtil.createTableIfMissing(conn, "history", historySchema, ""); + + if (!JDBCUtil.tableExists(conn, "sites")) { + String[] sitesSchema = { + "siteId INTEGER(5) PRIMARY KEY NOT NULL AUTO_INCREMENT", + "siteString VARCHAR(24) NOT NULL", + }; + JDBCUtil.createTableIfMissing(conn, "sites", sitesSchema, ""); + + String[] domainsSchema = { + "domain VARCHAR(128) PRIMARY KEY NOT NULL", + "siteId INTEGER(5) NOT NULL", + }; + JDBCUtil.createTableIfMissing(conn, "domains", domainsSchema, ""); + + Statement stmt = conn.createStatement(); + for (SiteData element : OOO_SITES) { + stmt.executeUpdate("insert into sites values(" + element.siteId + ", '" + + element.siteString + "')"); + stmt.executeUpdate("insert into domains values('" + element.domain + "', " + + element.siteId + ")"); + } + stmt.close(); + } + + String[] tagSchema = { + "tagId INTEGER PRIMARY KEY AUTO_INCREMENT", + "tag VARCHAR(255) NOT NULL", + "UNIQUE INDEX tag_index (tag)", + }; + JDBCUtil.createTableIfMissing(conn, "afftags", tagSchema, ""); + + String[] sessionsSchema = { + "authcode VARCHAR(32) NOT NULL PRIMARY KEY", + "userId INTEGER(10) NOT NULL", + "expires DATE NOT NULL", + "INDEX userid_index (userId)", + "INDEX expires_index (expires)" + }; + JDBCUtil.createTableIfMissing(conn, "sessions", sessionsSchema, ""); + + String[] pendersSchema = { + "secret VARCHAR(32) PRIMARY KEY NOT NULL", + "userId INTEGER(10) NOT NULL", + "persist TINYINT NOT NULL", + "inserted DATE NOT NULL", + }; + JDBCUtil.createTableIfMissing(conn, "penders", pendersSchema, ""); + + String[] auxSchema = { + "USER_ID INTEGER NOT NULL PRIMARY KEY", + "BIRTHDAY DATE NOT NULL", + "GENDER TINYINT NOT NULL", + "MISSIVE VARCHAR(255) NOT NULL", + }; + JDBCUtil.createTableIfMissing(conn, "AUXDATA", auxSchema, ""); + + String[] billAuxSchema = { + "USER_ID INTEGER NOT NULL PRIMARY KEY", + "FIRST_COIN_BUY DATETIME", + "LATEST_COIN_BUY DATETIME", + }; + JDBCUtil.createTableIfMissing(conn, "BILLAUXDATA", billAuxSchema, ""); + + String[] taintedIdentsSchema = { + "MACH_IDENT VARCHAR(255) NOT NULL", + "PRIMARY KEY (MACH_IDENT)", + }; + JDBCUtil.createTableIfMissing(conn, "TAINTED_IDENTS", taintedIdentsSchema, ""); + + String[] bannedIdentsSchema = { + "MACH_IDENT VARCHAR(255) NOT NULL", + "SITE_ID INTEGER(5) NOT NULL", + "PRIMARY KEY (MACH_IDENT, SITE_ID)", + }; + JDBCUtil.createTableIfMissing(conn, "BANNED_IDENTS", bannedIdentsSchema, ""); + + String[] userIdentsSchema = { + "USER_ID INTEGER UNSIGNED NOT NULL", + "MACH_IDENT VARCHAR(255) NOT NULL", + "PRIMARY KEY (USER_ID, MACH_IDENT)", + "INDEX (MACH_IDENT)", + }; + JDBCUtil.createTableIfMissing(conn, "USER_IDENTS", userIdentsSchema, ""); + + String[] interestedPartySchema = { + "PRODUCT VARCHAR(255) NOT NULL", + "EMAIL_ADDRESS VARCHAR(255) NOT NULL", + "RECORDED DATETIME NOT NULL", + }; + JDBCUtil.createTableIfMissing(conn, "INTERESTED_PARTIES", interestedPartySchema, ""); + } + + @Override + protected void createTables () + { + @SuppressWarnings({ "rawtypes", "unchecked" }) Table utable = + new Table(OOOUser.class, "users", "userId"); + _utable = utable; + _dtable = new Table(DetailedUser.class, "users", "userId"); + _atable = new Table(OOOAuxData.class, "AUXDATA", "USER_ID", true); + _batable = new Table(OOOBillAuxData.class, "BILLAUXDATA", "USER_ID", true); + _vtable = new Table(ValidateRecord.class, "penders", "secret"); + _itable = new Table(UserIdent.class, "USER_IDENTS", "USER_ID", true); + _ttable = new Table(TaintedIdent.class, "TAINTED_IDENTS", "MACH_IDENT", true); + _btable = new Table( + BannedIdent.class, "BANNED_IDENTS", new String[] { "MACH_IDENT", "SITE_ID" }, true); + _htable = new Table(HistoricalUser.class, "history", "userId"); + _tagtable = new Table(AffiliateTag.class, "afftags", "tagId"); + } + + protected void populateUser (User user, Username uname, Password pass, String email, + int siteId, int tagId) + { + // fill in the base user information + super.populateUser(user, uname, pass, "", email, siteId); + + // fill in the ooo-specific user information + OOOUser ouser = (OOOUser)user; + ouser.tokens = new byte[0]; + ouser.spots = ""; + ouser.affiliateTagId = tagId; + } + + /** Used to auto-create site table records for our websites. */ + protected static class SiteData + { + public int siteId; + public String siteString; + public String domain; + public SiteData (int siteId, String siteString, String domain) { + this.siteId = siteId; + this.siteString = siteString; + this.domain = domain; + } + } + + /** The table used to select detail records. */ + protected Table _dtable; + + /** The table used to store user auxiliary data. */ + protected Table _atable; + + /** The table used to store user billing auxiliary data. */ + protected Table _batable; + + /** The table used to store pending email validations. */ + protected Table _vtable; + + /** The table used to store the user machine ident mapping. */ + protected Table _itable; + + /** The table used to store the tainted machine idents. */ + protected Table _ttable; + + /** The table used to store the banned machine idents. */ + protected Table _btable; + + /** The table used to store our total registration history. */ + protected Table _htable; + + /** The table used to store our affiliate tag mappings. */ + protected Table _tagtable; + + /** The number of days in the past from now where we no longer + * consider an account as 'recent' */ + protected static final int RECENT_ACCOUNT_CUTOFF = -3*30; + + /** The number of free accounts that can be created per machine. */ + protected static final int MAX_FREE_ACCOUNTS_PER_MACHINE = 2; + + /** The number of millis seconds in a minute*/ + protected static final int MILLIS_PER_MINUTE = 1000 * 60; + + /** Filter coin purchasers this many users at a time*/ + protected static final int FILTER_COIN_BATCH = 1000; + + /** Used to auto-create our site table. */ + protected static final SiteData[] OOO_SITES = { + new SiteData(OOOUser.PUZZLEPIRATES_SITE_ID, "puzzlepirates", "puzzlepirates.com"), + new SiteData(OOOUser.GAMEGARDENS_SITE_ID, "gardens", "gamegardens.com"), + new SiteData(OOOUser.BANGHOWDY_SITE_ID, "bang", "banghowdy.com"), + }; +} diff --git a/src/main/java/com/threerings/user/OOOXmlRpcService.java b/src/main/java/com/threerings/user/OOOXmlRpcService.java new file mode 100644 index 0000000..d0ce103 --- /dev/null +++ b/src/main/java/com/threerings/user/OOOXmlRpcService.java @@ -0,0 +1,20 @@ +// +// $Id$ + +package com.threerings.user; + +/** + * Defines an XML-RPC interface to certain ooouser services. This is implemented by the register + * webapp, but can be implemented by other systems that wish to provide API compatibility with a + * potentially different backend implementation. + */ +public interface OOOXmlRpcService +{ + /** + * Returns true if the supplied credentials are valid, false otherwise. + * + * @param username the name of the user in question. + * @param password the MD5 encoded password for the user. + */ + boolean authUser (String username, String password); +} diff --git a/src/main/java/com/threerings/user/ReferralRecord.java b/src/main/java/com/threerings/user/ReferralRecord.java new file mode 100644 index 0000000..30642bd --- /dev/null +++ b/src/main/java/com/threerings/user/ReferralRecord.java @@ -0,0 +1,25 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +/** + * Records information on a particular referral. + */ +public class ReferralRecord +{ + /** A unique identifier for this referral record. */ + public int referralId; + + /** The date on which the referral was made (used to expire old + * unutilized referrals). */ + public Date recorded; + + /** The OOO user id of the referring user. */ + public int referrerId; + + /** Data associated with this referral record. */ + public String data; +} diff --git a/src/main/java/com/threerings/user/ReferralRepository.java b/src/main/java/com/threerings/user/ReferralRepository.java new file mode 100644 index 0000000..1cee973 --- /dev/null +++ b/src/main/java/com/threerings/user/ReferralRepository.java @@ -0,0 +1,169 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.SQLException; +import java.sql.Statement; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JORARepository; +import com.samskivert.jdbc.jora.Table; + +import static com.threerings.user.Log.log; + +/** + * Tracks the data needed for our personal referral system whereby one + * user sends a referral link (or email) to their friend and we track the + * original referring user if their referred friend actually registers + * within some reasonable time frame (like a couple of weeks). + */ +public class ReferralRepository extends JORARepository +{ + /** + * The database identifier used when establishing a database + * connection. This value being referraldb. + */ + public static final String REFERRAL_DB_IDENT = "referraldb"; + + /** + * Creates the repository and prepares it for operation. + * + * @param provider the database connection provider. + */ + public ReferralRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, REFERRAL_DB_IDENT); + + // figure out whether or not the referral system is in use + execute(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + _active = JDBCUtil.tableExists(conn, "REFERRAL"); + if (!_active) { + log.info("No referral table. Disabling."); + } + return null; + } + }); + } + + /** + * Records a new referral record in the system and returns the unique + * identifier for said record. + */ + public int recordReferral (int referrerId, String data) + throws PersistenceException + { + if (!_active) { + throw new PersistenceException("Referral repository is not available."); + } + + final ReferralRecord record = new ReferralRecord(); + record.recorded = new Date(System.currentTimeMillis()); + record.referrerId = referrerId; + record.data = data; + record.referralId = insert(_rtable, record); + + checkPurge(); + return record.referralId; + } + + /** + * Looks up a referral record with the specified id, returning null if + * no matching record could be found. + */ + public ReferralRecord lookupReferral (int referralId) + throws PersistenceException + { + return lookupReferralBy("where REFERRAL_ID = " + referralId); + } + + /** + * Looks up a referral record with the specified referring user id, + * returning null if no matching record could be found. + */ + public ReferralRecord lookupReferrer (int referrerId) + throws PersistenceException + { + return lookupReferralBy("where REFERRER_ID = " + referrerId); + } + + /** Looks up a referral record matching the specified query. */ + protected ReferralRecord lookupReferralBy (final String query) + throws PersistenceException + { + if (!_active) { + return null; + } + + ReferralRecord record = load(_rtable, query); + checkPurge(); + return record; + } + + /** + * Used to periodically purge old referral records from the + * repository. + */ + protected void checkPurge () + { + long now = System.currentTimeMillis(); + if (now - _lastPurge > PURGE_INTERVAL) { + _lastPurge = now; + try { + purgeStaleReferrals(); + } catch (PersistenceException pe) { + log.warning("Error purging referrals: " + pe); + } + } + } + + /** + * Purges stale referral records from the repository. + */ + protected void purgeStaleReferrals () + throws PersistenceException + { + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String pquery = "delete from REFERRAL " + + "where DATE_SUB(NOW(), INTERVAL 1 MONTH) > RECORDED;"; + Statement stmt = conn.createStatement(); + try { + stmt.executeUpdate(pquery); + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + @Override + protected void createTables () + { + _rtable = new Table(ReferralRecord.class, "REFERRAL", "REFERRAL_ID", true); + } + + /** The table used to new billing actions that have taken place. */ + protected Table _rtable; + + /** If we don't detect our tables, we deactivate ourselves. */ + protected boolean _active; + + /** The last time we purged the repository of stale records. */ + protected long _lastPurge; + + /** We purge the repository every three hours of stale records. */ + protected static final long PURGE_INTERVAL = 3 * 60 * 60 * 1000L; +} diff --git a/src/main/java/com/threerings/user/RepositoryManager.java b/src/main/java/com/threerings/user/RepositoryManager.java new file mode 100644 index 0000000..c4b934c --- /dev/null +++ b/src/main/java/com/threerings/user/RepositoryManager.java @@ -0,0 +1,59 @@ +// +// $Id$ + +/* + * Copyright (C) 2007-2010 Three Rings Design, Inc. + * Copyright 1999,2004 The Apache Software Foundation. + */ + +package com.threerings.user; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.StaticConnectionProvider; +import com.samskivert.servlet.JDBCTableSiteIdentifier; +import com.samskivert.servlet.SiteIdentifier; +import com.samskivert.util.Config; + +/** + * Encapsulates multiple repositories, and offers public access to all of them. + */ +public class RepositoryManager { + + /** The repo manager will be stored in context under this attribute name */ + public static final String ATTRIBUTE_NAME = RepositoryManager.class.getName(); + + /** Our User Manager */ + public OOOUserManager userRepository; + + /** Our referral repository. */ + public ReferralRepository referralRepository; + + /** Our site identification repository. */ + public SiteIdentifier siteRepository; + + /** For logging tracking events */ + public TrackingRepository trackingRepository; + + /** + * Initialize a new set of repositories. + * @param config + */ + public RepositoryManager (Config config) + throws PersistenceException + { + // create a static connection provider + StaticConnectionProvider conn = new StaticConnectionProvider(config.getSubProperties("db")); + + /* create the referral repository */ + referralRepository = new ReferralRepository(conn); + + /* create a repository of users */ + userRepository = new OOOUserManager(config.getSubProperties("oooauth"), conn); + + /* create a repository of sites */ + siteRepository = new JDBCTableSiteIdentifier(conn); + + /* create a repository for tracking */ + trackingRepository = new TrackingRepository(conn); + } +} diff --git a/src/main/java/com/threerings/user/RewardInfo.java b/src/main/java/com/threerings/user/RewardInfo.java new file mode 100644 index 0000000..19ebaa1 --- /dev/null +++ b/src/main/java/com/threerings/user/RewardInfo.java @@ -0,0 +1,45 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +import com.samskivert.util.StringUtil; + +/** + * An object representation of the persistent reward info stored in the {@link RewardRepository} + * REWARD_INFO table. + */ +public class RewardInfo +{ + /** Unique reward id. */ + public int rewardId; + + /** Reward description. */ + public String description; + + /** A coupon code required to activate this reward or null. */ + public String couponCode; + + /** Game specific reward data. */ + public String data; + + /** Date when the reward expires. */ + public Date expiration; + + /** Max userid that can redeem this reward. */ + public int maxEligibleId; + + /** Number of activated rewards. */ + public int activations; + + /** Number of claimed rewards. */ + public int redemptions; + + @Override // from Object + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/RewardRecord.java b/src/main/java/com/threerings/user/RewardRecord.java new file mode 100644 index 0000000..4ce8a1a --- /dev/null +++ b/src/main/java/com/threerings/user/RewardRecord.java @@ -0,0 +1,41 @@ +// +// $Id$ + +package com.threerings.user; + +import com.samskivert.util.StringUtil; + +/** + * An object representation of the persistent rewards stored in the {@link RewardRepository} REWARDS + * table. + */ +public class RewardRecord +{ + /** RewardInfo reward id. */ + public int rewardId; + + /** Account name. */ + public String account; + + /** Unique identification value. */ + public String redeemerIdent; + + /** + * A generic parameter string that can tell the reward handler more specifically when and what + * item, etc. to hand out for this instance of the reward. + */ + public String param; + + /** + * Constructs a blank RewardRecord for unserialization from the repository. + */ + public RewardRecord () + { + } + + @Override // from Object + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/RewardRepository.java b/src/main/java/com/threerings/user/RewardRepository.java new file mode 100644 index 0000000..e87fc16 --- /dev/null +++ b/src/main/java/com/threerings/user/RewardRepository.java @@ -0,0 +1,450 @@ +// +// $Id$ + +package com.threerings.user; + +import static com.threerings.user.Log.log; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.List; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.JORARepository; +import com.samskivert.jdbc.jora.Cursor; +import com.samskivert.jdbc.jora.FieldMask; +import com.samskivert.jdbc.jora.Table; + +/** + * Maintains persistent reward information. + */ +public class RewardRepository extends JORARepository +{ + /** + * Creates the repository with the specified connection provider. + * + * @exception PersistenceException thrown if an error occurs communicating with the underlying + * persistence facilities. + */ + public RewardRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, OOOUserRepository.USER_REPOSITORY_IDENT); + } + + /** + * Creates a new RewardInfo record in the database. info should have the + * description, data and expiration filled in. + */ + public void createReward (RewardInfo info) + throws PersistenceException + { + info.maxEligibleId = getMaxUserId(); + store(_infoTable, info); + } + + /** + * Immediately expires a reward by setting the expiration to the current date then making a + * call to purgeExpiredRewards. + */ + public void expireReward (int rewardId) + throws PersistenceException + { + RewardInfo info = new RewardInfo(); + info.rewardId = rewardId; + info.expiration = new Date(System.currentTimeMillis()); + updateField(_infoTable, info, "expiration"); + purgeExpiredRewards(); + } + + /** + * Attempt to activate the reward for an account. If this account has not already activated + * the reward, a new RewardRecord will be created. Returns true if a new RewardRecord is + * created, false otherwise. + */ + public boolean activateReward (int rewardId, String account) + throws PersistenceException + { + return activateReward(rewardId, account, null); + } + + public boolean activateReward (int rewardId, String account, String param) + throws PersistenceException + { + final RewardRecord rr = new RewardRecord(); + rr.rewardId = rewardId; + rr.account = account; + rr.param = param == null ? "" : param; + // try to insert it; catching duplicate row exceptions and reporting that the reward is + // already activated + return executeUpdate(new Operation() { + public Boolean invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + try { + _rewardsTable.insert(conn, rr); + return true; + } catch (SQLException sqe) { + if (liaison.isDuplicateRowException(sqe)) { + return false; + } else { + throw sqe; + } + } + } + }); + } + + /** + * Activates monthly rewards of the specified ID for the account between start and end. If they + * already have monthly rewards for this ID, will add new ones only at appropriate monthly + * intervals from the existing ones. + */ + public boolean activateMonthlyRewards (final int rewardId, final String account, + final java.util.Date start, final java.util.Date end) + throws PersistenceException + { + // Setup an example to find relevant rewards. + final RewardRecord example = new RewardRecord(); + example.rewardId = rewardId; + example.account = account; + final FieldMask mask = _rewardsTable.getFieldMask(); + mask.setModified("rewardId"); + mask.setModified("account"); + + final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + return executeUpdate(new Operation() { + public Boolean invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + // Find the most recent reward this account already has of this type. + java.util.Date latest = null; + Cursor matches = + _rewardsTable.queryByExample(conn, example, mask); + RewardRecord rec; + while ((rec = matches.next()) != null) { + if (rec.param == null) { + log.warning("Missing monthly reward param: ", "rewardId", rewardId, + "account", account); + continue; + } + try { + java.util.Date thisDate = dateFormat.parse(rec.param); + if (latest == null || thisDate.after(latest)) { + latest = thisDate; + } + } catch (ParseException pe) { + log.warning("Bogus reward param: ", "rewardId", rewardId, + "account", account, "param", rec.param); + continue; + } + } + + // We use the maximum of our provided start time and a month-after-latest + // as the first time we'll input a reward. Note that this can result + // in no rewards being given. + Calendar startCal = Calendar.getInstance(); + if (latest != null) { + startCal.setTime(latest); + startCal.add(Calendar.MONTH, 1); + if (startCal.getTime().before(start)) { + startCal.setTime(start); + } + } else { + startCal.setTime(start); + } + + // Add in any appropriate rewards between the modified start and end. + while (startCal.getTime().before(end)) { + final RewardRecord rr = new RewardRecord(); + rr.rewardId = rewardId; + rr.account = account; + rr.param = dateFormat.format(startCal.getTime()); + _rewardsTable.insert(conn, rr); + startCal.add(Calendar.MONTH, 1); + } + + return true; + } + }); + + } + + /** + * Deactivates all rewards of the specified ID and account during the time window. + * Returns the number of rewards deleted. + */ + public int deactivateMonthlyRewards (final int rewardId, final String account, + final java.util.Date start, final java.util.Date end) + throws PersistenceException + { + return executeUpdate(new Operation() { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String delQuery = "delete from REWARD_RECORDS where REWARD_ID = ? and ACCOUNT = ?" + + " and PARAM >= ? and PARAM <= ? and REDEEMER_IDENT is null"; + + final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String startStr = dateFormat.format(start); + String endStr = dateFormat.format(end); + + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement(delQuery); + stmt.setInt(1, rewardId); + stmt.setString(2, account); + stmt.setString(3, startStr); + stmt.setString(4, endStr); + return stmt.executeUpdate(); + } finally { + JDBCUtil.close(stmt); + } + } + }); + } + + /** + * Returns all currently active rewards. + */ + public List loadActiveRewards () + throws PersistenceException + { + return loadAll(_infoTable, "where EXPIRATION > NOW()"); + } + + /** + * Returns all rewards. + */ + public List loadRewards () + throws PersistenceException + { + return loadAll(_infoTable, "order by REWARD_ID desc"); + } + + /** + * Returns all RewardRecords that match the given account name and whose eligible + * date is in the past. The returned list will be sorted by rewardId. + */ + public List loadActivatedRewards (String account) + throws PersistenceException + { + String condition = "where ACCOUNT = " + JDBCUtil.escape(account) + + " order by REWARD_ID"; + return loadAll(_rewardsTable, condition); + } + + /** + * Returns all RewardRecords that match either the account or redeemer identifier. + * The returned list will be sorted by rewardId. + */ + public List loadActivatedRewards (String account, String redeemerIdent) + throws PersistenceException + { + String condition = "where ACCOUNT = " + JDBCUtil.escape(account) + + " or REDEEMER_IDENT = " + JDBCUtil.escape(redeemerIdent) + + " order by REWARD_ID, PARAM"; + return loadAll(_rewardsTable, condition); + } + + /** + * Returns all RewardRecords for a specified rewardId that match + * either the account or redeemer identifier. + */ + public List loadActivatedReward ( + String account, String redeemerIdent, int rewardId) + throws PersistenceException + { + String condition = "where REWARD_ID = " + rewardId + + " and (ACCOUNT = " + JDBCUtil.escape(account) + + " or REDEEMER_IDENT = " + JDBCUtil.escape(redeemerIdent) + ")"; + return loadAll(_rewardsTable, condition); + } + + /** + * Returns all RewardRecords for a specified rewardId and + * param that match either the account or redeemer identifier. + */ + public List loadActivatedReward ( + String account, String redeemerIdent, int rewardId, String param) + throws PersistenceException + { + String paramStr = (param == null) ? "PARAM is NULL" : "PARAM = " + JDBCUtil.escape(param); + String condition = "where REWARD_ID = " + rewardId + + " and " + paramStr + + " and (ACCOUNT = " + JDBCUtil.escape(account) + + " or REDEEMER_IDENT = " + JDBCUtil.escape(redeemerIdent) + ")"; + return loadAll(_rewardsTable, condition); + } + + /** + * Updates the supplied RewardRecord with the indicated redeemerIdent and store it + * to the database. + */ + public boolean redeemReward (RewardRecord record, String redeemerIdent) + throws PersistenceException + { + String update = "update " + _rewardsTable.getName() + " set REDEEMER_IDENT = " + + JDBCUtil.escape(redeemerIdent) + " where REDEEMER_IDENT is NULL and REWARD_ID = " + + record.rewardId + " and ACCOUNT = " + JDBCUtil.escape(record.account) + + " and PARAM = " + JDBCUtil.escape(record.param); + return update(update) > 0; + } + + /** + * Summarizes the reward data and purges expired rewards. + */ + public void purgeExpiredRewards () + throws PersistenceException + { + executeUpdate(new Operation () { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + summarizeAndUpdate(conn, "", "ACTIVATIONS"); + summarizeAndUpdate(conn, "and REDEEMER_IDENT is NOT NULL", "REDEMPTIONS"); + return null; + } + }); + + update("delete from REWARD_RECORDS using REWARD_RECORDS, REWARD_INFO " + + "where REWARD_INFO.REWARD_ID = REWARD_RECORDS.REWARD_ID " + + "and REWARD_INFO.EXPIRATION <= NOW()"); + } + + /** + * Summarizes the reward data and stores it in the reward info records. + */ + protected void summarizeAndUpdate (Connection conn, String condition, String column) + throws PersistenceException, SQLException + { + String activatedQuery = "select count(*), REWARD_INFO.REWARD_ID " + + "from REWARD_RECORDS, REWARD_INFO " + + "where REWARD_INFO.REWARD_ID = REWARD_RECORDS.REWARD_ID " + condition + + " group by REWARD_ID"; + String activatedUpdate = "update REWARD_INFO set " + column + " = ? where REWARD_ID = ?"; + PreparedStatement ustmt = null; + Statement stmt = null; + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(activatedQuery); + ustmt = conn.prepareStatement(activatedUpdate); + while (rs.next()) { + ustmt.setInt(1, rs.getInt(1)); + ustmt.setInt(2, rs.getInt(2)); + ustmt.executeUpdate(); + } + } finally { + JDBCUtil.close(ustmt); + JDBCUtil.close(stmt); + } + } + + /** + * Returns the max userid currently in use. + */ + protected int getMaxUserId () + throws PersistenceException + { + return execute(new Operation () { + public Integer invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + String query = "select MAX(userId) from users"; + Statement stmt = null; + int maxUserId = 0; + + try { + stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query); + if (rs.next()) { + maxUserId = rs.getInt(1); + } else { + log.warning("No users found!?"); + } + } finally { + JDBCUtil.close(stmt); + } + + return maxUserId; + } + }); + } + + @Override + protected void migrateSchema (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String[] rewardInfoTable = { + "REWARD_ID INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY", + "DESCRIPTION VARCHAR(255) NOT NULL", + "COUPON_CODE VARCHAR(255)", + "DATA VARCHAR(255) NOT NULL", + "EXPIRATION DATE NOT NULL", + "MAX_ELIGIBLE_ID INTEGER NOT NULL", + "ACTIVATIONS INTEGER NOT NULL", + "REDEMPTIONS INTEGER NOT NULL" + }; + JDBCUtil.createTableIfMissing(conn, "REWARD_INFO", rewardInfoTable, ""); + + // TEMP: (2006-12-22) add COUPON_CODE column + JDBCUtil.addColumn(conn, "REWARD_INFO", "COUPON_CODE", "VARCHAR(255)", "DESCRIPTION"); + // END TEMP + + String[] rewardsTable = { + "REWARD_ID INTEGER NOT NULL", + "ACCOUNT VARCHAR(255)", + "REDEEMER_IDENT VARCHAR(64)", + "PARAM VARCHAR(255)", + "PRIMARY KEY (REWARD_ID, ACCOUNT, PARAM)", + "INDEX (ACCOUNT)", + "INDEX (REDEEMER_IDENT)" + }; + JDBCUtil.createTableIfMissing(conn, "REWARD_RECORDS", rewardsTable, ""); + + // Add a parameter column. This allows a particular reward to be granted more than once, and + // pass specific details to the reward handler. + if (!JDBCUtil.tableContainsColumn(conn, "REWARD_RECORDS", "PARAM")) { + // unfortunately, we need to recreate the primary key + JDBCUtil.dropPrimaryKey(conn, "REWARD_RECORDS"); + + // add generic parameter column, and include + JDBCUtil.addColumn(conn, "REWARD_RECORDS", "PARAM", "VARCHAR(255), " + + "ADD PRIMARY KEY (REWARD_ID, ACCOUNT, PARAM)", null); + } + } + + @Override + protected void createTables () + { + _infoTable = new Table( + RewardInfo.class, "REWARD_INFO", REWARD_INFO_PRIMARY_KEY, true); + _rewardsTable = new Table( + RewardRecord.class, "REWARD_RECORDS", REWARD_RECORDS_PRIMARY_KEYS, true); + } + + /** The primary key for the reward info table. */ + protected static final String REWARD_INFO_PRIMARY_KEY = "REWARD_ID"; + + /** The primary keys for the rewards table. */ + protected static final String[] REWARD_RECORDS_PRIMARY_KEYS = { + "REWARD_ID", "ACCOUNT", "PARAM" + }; + + /** The table used to store reward information. */ + protected Table _infoTable; + + /** The table used to store activated rewards. */ + protected Table _rewardsTable; +} diff --git a/src/main/java/com/threerings/user/TaintedIdent.java b/src/main/java/com/threerings/user/TaintedIdent.java new file mode 100644 index 0000000..98f9554 --- /dev/null +++ b/src/main/java/com/threerings/user/TaintedIdent.java @@ -0,0 +1,24 @@ +// +// $Id$ + +package com.threerings.user; + +/** + * Represents a row in the TAINTED_IDENTS table. + */ +public class TaintedIdent +{ + /** A 'unique' id for a specific machine we have seen. */ + public String machIdent; + + /** Blank constructor for the unserialization buisness. */ + public TaintedIdent () + { + } + + @Override + public String toString () + { + return machIdent; + } +} diff --git a/src/main/java/com/threerings/user/TrackingRepository.java b/src/main/java/com/threerings/user/TrackingRepository.java new file mode 100644 index 0000000..2e81015 --- /dev/null +++ b/src/main/java/com/threerings/user/TrackingRepository.java @@ -0,0 +1,90 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.PreparedStatement; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.SimpleRepository; + +/** + * Manages database access for doing our own quick and dirty tracking ala clientstep. + */ +public class TrackingRepository extends SimpleRepository +{ + /** Name of the tracking table. */ + public static final String TRACKING_TABLE = "TRACKING"; + + /** + * The database identifier used when establishing a database + * connection. This value being trackingdb. + */ + public static final String TRACKING_DB_IDENT = "trackingdb"; + + /** + * Creates the repository and prepares it for operation. + * + * @param provider the database connection provider. + */ + public TrackingRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, TRACKING_DB_IDENT); + } + + /** + * Records a tracking event to the database. + */ + public void addTrackingEvent (final String event, final String description, + final int accountId, final int siteId) + throws PersistenceException + { + executeUpdate(new Operation() { + public Object invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement("insert into " + TRACKING_TABLE + + " (TIME, EVENT, DESCRIPTION, ACCOUNT_ID, SITE_ID)" + + " values (NOW(), ?, ?, ?, ?)"); + stmt.setString(1, event); + stmt.setString(2, description); + stmt.setInt(3, accountId); + stmt.setInt(4, siteId); + + JDBCUtil.checkedUpdate(stmt, 1); + } finally { + JDBCUtil.close(stmt); + } + + return null; + } + }); + } + + @Override + protected void migrateSchema (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + String[] TrackingTable = { + "TIME DATETIME NOT NULL", + "EVENT VARCHAR(255)", + "DESCRIPTION VARCHAR(255)", + "ACCOUNT_ID INTEGER", + "SITE_ID INTEGER", + }; + JDBCUtil.createTableIfMissing(conn, TRACKING_TABLE, TrackingTable, ""); + + if (!JDBCUtil.tableContainsColumn(conn, TRACKING_TABLE, "ACCOUNT_ID")) { + JDBCUtil.addColumn(conn, TRACKING_TABLE, "ACCOUNT_ID", "INTEGER", "DESCRIPTION"); + JDBCUtil.addColumn(conn, TRACKING_TABLE, "SITE_ID", "INTEGER", "ACCOUNT_ID"); + } + } +} diff --git a/src/main/java/com/threerings/user/UserDataUtil.java b/src/main/java/com/threerings/user/UserDataUtil.java new file mode 100644 index 0000000..d83894e --- /dev/null +++ b/src/main/java/com/threerings/user/UserDataUtil.java @@ -0,0 +1,241 @@ +// +// $Id$ + +package com.threerings.user; + +import javax.servlet.http.HttpServletRequest; + +import com.samskivert.net.MailUtil; +import com.samskivert.servlet.util.DataValidationException; +import com.samskivert.servlet.util.ParameterUtil; +import com.samskivert.util.StringUtil; + +/** + * Miscellaneous user data related routines. + */ +public class UserDataUtil +{ + /** The minimum length of a valid password. */ + public static final int MIN_PASSWORD_LENGTH = 4; + + /** The minimum length of a valid username. */ + public static final int MIN_USERNAME_LENGTH = 3; + + /** The maximum length of a valid username. */ + public static final int MAX_USERNAME_LENGTH = 12; + + /** + * Returns the username in the supplied request, or throws an + * informative exception if the username is absent or invalid. + */ + public static String getUsername (HttpServletRequest req, String page) + throws DataValidationException + { + String username = ParameterUtil.requireParameter( + req, "username", page + ".error.missing_username"); + username = username.trim(); + int len = username.length(); + if (len < MIN_USERNAME_LENGTH || len > MAX_USERNAME_LENGTH || + !username.matches("[a-zA-Z0-9_]+")) { + throw new DataValidationException(page + ".error.invalid_username"); + } + return username; + } + + /** + * Returns the password in the supplied request, or throws an + * informative exception if the password is absent or invalid. + */ + public static String getPassword ( + HttpServletRequest req, String page, String ptype) + throws DataValidationException + { + return getPassword(req, page, ptype, false); + } + + /** + * Returns the password in the supplied request, or throws an + * informative exception if the password is absent or invalid. + * + * @optional If true, null may be returned and if the parameter is absent no exception will be + * thrown. If the parameter is present it is still checked for validity. + */ + public static String getPassword ( + HttpServletRequest req, String page, String ptype, boolean optional) + throws DataValidationException + { + String password = optional ? ParameterUtil.getParameter(req, ptype, true) : + ParameterUtil.requireParameter(req, ptype, page + ".error.missing_" + ptype); + if (password != null) { + checkPassword(page, password, ptype); + } + return password; + } + + /** + * Throws an informative exception if the password is absent or + * invalid. + */ + public static void checkPassword ( + String page, String password, String ptype) + throws DataValidationException + { + int len = password.length(); + if (len < MIN_PASSWORD_LENGTH) { + throw new DataValidationException( + page + ".error.invalid_" + ptype); + } + } + + /** + * Returns the email in the supplied request, or throws an informative + * exception if the email is absent or invalid. + */ + public static String getEmail ( + HttpServletRequest req, String page, boolean require) + throws DataValidationException + { + String email = ParameterUtil.getParameter(req, "email", true); + if (StringUtil.isBlank(email)) { + if (require) { + throw new DataValidationException( + page + ".error.missing_email"); + } + return ""; + } + email = email.trim(); + if (email.length() > MAX_EMAIL_LENGTH || + !MailUtil.isValidAddress(email)) { + throw new DataValidationException(page + ".error.invalid_email"); + } + return email; + } + + /** + * Returns the specified partial (first or last) name in the supplied + * request, or throws an informative exception if the name is absent + * or invalid. + */ + public static String getName ( + HttpServletRequest req, String page, String pname) + throws DataValidationException + { + String name = ParameterUtil.requireParameter( + req, pname, page + ".error.missing_" + pname).trim(); + int len = name.length(); + if (len < MIN_NAME_LENGTH || len > MAX_NAME_LENGTH || + !name.matches("[a-zA-Z]+")) { + throw new DataValidationException( + page + ".error.invalid_" + pname); + } + return name; + } + + /** + * Returns the state in the supplied request, or throws an informative + * exception if the state is absent or invalid. + */ + public static String getState (HttpServletRequest req, String page) + throws DataValidationException + { + String state = ParameterUtil.getParameter(req, "state", false); + state = state.trim(); + if (state.length() > MAX_STATE_LENGTH) { + throw new DataValidationException( + page + ".error.invalid_state"); + } + return state; + } + + /** + * Returns the country in the supplied request, or throws an + * informative exception if the country is absent or invalid. + */ + public static String getCountry (HttpServletRequest req, String page) + throws DataValidationException + { + String country = ParameterUtil.requireParameter( + req, "country", page + ".error.missing_country"); + country = country.trim(); + int len = country.length(); + if (len < MIN_COUNTRY_LENGTH || len > MAX_COUNTRY_LENGTH) { + throw new DataValidationException( + page + ".error.invalid_country"); + } + return country; + } + + /** + * Returns the missive in the supplied request, or throws an + * informative exception if the missive is absent or invalid. + */ + public static String getMissive (HttpServletRequest req, String page) + throws DataValidationException + { + String missive = ParameterUtil.getParameter(req, "missive", false); + missive = missive.trim(); + if (missive.length() > MAX_MISSIVE_LENGTH) { + throw new DataValidationException( + page + ".error.invalid_missive"); + } + return missive; + } + + /** + * Returns 1 if the named parameter is present in the supplied + * request, 0 if not. + */ + public static byte isChecked (HttpServletRequest req, String name) + throws DataValidationException + { + String value = ParameterUtil.getParameter(req, name, true); + return (byte)((value != null) ? 1 : 0); + } + + /** + * Returns the value of the given select menu, or throws an error if + * the value is not defined or 0 (denoting the default "[Select One]" + * selection.) + */ + public static byte requireSelectItem ( + HttpServletRequest req, String name, String error) + throws DataValidationException + { + byte value = (byte)ParameterUtil.getIntParameter(req, name, 0, error); + if (value == 0) { + throw new DataValidationException(error); + } + return value; + } + + /** The maximum length of a valid email address. */ + protected static final int MAX_EMAIL_LENGTH = 128; + + /** The minimum length of a valid name in characters. */ + protected static final int MIN_NAME_LENGTH = 1; + + /** The maximum length of a valid name in characters. */ + protected static final int MAX_NAME_LENGTH = 63; + + /** The minimum birth year a user can enter. God help us if we have + * many centegenarians. */ + protected static final int MIN_BIRTHYEAR = 1900; + + /** The minimum birth date a user can enter. */ + protected static final int MIN_BIRTHDATE = 1; + + /** The maximum birth date a user can enter. */ + protected static final int MAX_BIRTHDATE = 31; + + /** The minimum length of a valid country in characters. */ + protected static final int MIN_COUNTRY_LENGTH = 2; + + /** The maximum length of a valid state in characters. */ + protected static final int MAX_STATE_LENGTH = 64; + + /** The maximum length of a valid country in characters. */ + protected static final int MAX_COUNTRY_LENGTH = 64; + + /** The maximum length of a valid missive in characters. */ + protected static final int MAX_MISSIVE_LENGTH = 32768; +} diff --git a/src/main/java/com/threerings/user/UserIdent.java b/src/main/java/com/threerings/user/UserIdent.java new file mode 100644 index 0000000..c470218 --- /dev/null +++ b/src/main/java/com/threerings/user/UserIdent.java @@ -0,0 +1,28 @@ +// +// $Id$ + +package com.threerings.user; + +/** + * Represents a row in the USER_IDENT table mapping a user to all their + * machine identifieres. + */ +public class UserIdent +{ + /** The id of the user in question. */ + public int userId; + + /** A 'unique' id for a specific machine we have seen the user come from. */ + public String machIdent; + + /** Construct a blank record for unserialization purposes. */ + public UserIdent () + { + } + + @Override + public String toString () + { + return "[id=" + userId + ", ident=" + machIdent + "]"; + } +} diff --git a/src/main/java/com/threerings/user/ValidateRecord.java b/src/main/java/com/threerings/user/ValidateRecord.java new file mode 100644 index 0000000..b608c1e --- /dev/null +++ b/src/main/java/com/threerings/user/ValidateRecord.java @@ -0,0 +1,21 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +/** + * Maintains information on users that have not yet validated their + * account. + */ +public class ValidateRecord +{ + public String secret; + + public int userId; + + public boolean persist; + + public Date inserted; +} diff --git a/src/main/java/com/threerings/user/YoBetaInfo.java b/src/main/java/com/threerings/user/YoBetaInfo.java new file mode 100644 index 0000000..c2d4080 --- /dev/null +++ b/src/main/java/com/threerings/user/YoBetaInfo.java @@ -0,0 +1,66 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Date; + +import com.samskivert.util.StringUtil; + +/** + * A record detailing a user's non-critical responses to the Yohoho beta + * registration. + */ +public class YoBetaInfo +{ + /** The user id of the user. */ + public int userId; + + /** The user's birthday. */ + public Date birthday; + + /** The user's state or province. */ + public String state; + + /** The user's country. */ + public String country; + + /** The user's connection speed. */ + public byte connection; + + /** The user's computer operating system. */ + public byte os; + + /** The user's computer CPU speed. */ + public byte cpu; + + /** The user's computer memory size. */ + public byte memory; + + /** Whether the user has played an MMORPG. */ + public byte playMmorpg; + + /** Whether the user has played a beta MMORPG. */ + public byte playBetaMmorpg; + + /** Whether the user has played a text MUD. */ + public byte playMud; + + /** Whether the user plays puzzle games. */ + public byte playPuzzle; + + /** The user's testing availability this summer. */ + public byte summer; + + /** The source from which the user heard about Yohoho. */ + public byte source; + + /** The user's personal missive to us. */ + public String missive; + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/YoBetaRepository.java b/src/main/java/com/threerings/user/YoBetaRepository.java new file mode 100644 index 0000000..758f32c --- /dev/null +++ b/src/main/java/com/threerings/user/YoBetaRepository.java @@ -0,0 +1,139 @@ +// +// $Id$ + +package com.threerings.user; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import com.samskivert.io.PersistenceException; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.jdbc.DatabaseLiaison; +import com.samskivert.jdbc.JDBCUtil; +import com.samskivert.jdbc.JORARepository; +import com.samskivert.jdbc.jora.FieldMask; +import com.samskivert.jdbc.jora.Table; + +import static com.threerings.user.Log.log; + +/** + * Provides facilities for saving a user's Yohoho beta information to the + * persistent beta info table in the database. + */ +public class YoBetaRepository extends JORARepository +{ + /** + * The database identifier used when establishing a database + * connection. This value being yobetadb. + */ + public static final String YOBETA_DB_IDENT = "yobetadb"; + + /** + * Creates the yo beta repository. + * + * @param provider the database connection provider. + */ + public YoBetaRepository (ConnectionProvider provider) + throws PersistenceException + { + super(provider, YOBETA_DB_IDENT); + + // figure out whether or not the YOBETA table is in use on this system + execute(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws PersistenceException, SQLException + { + _active = JDBCUtil.tableExists(conn, YOBETA_TABLE_NAME); + if (!_active) { + log.info("No beta repository table. Disabling."); + } + return null; + } + }); + } + + /** + * Inserts the supplied yo beta info for the specified user into the + * database. + */ + public void insertYoBetaInfo (final YoBetaInfo ybi) + throws PersistenceException + { + if (!_active) { + return; + } + insert(_table, ybi); + } + + /** + * Loads and returns the yo beta info for the specified user id from + * the database, or null if no beta info for the user + * exists. + */ + public YoBetaInfo loadYoBetaInfo (final int userId) + throws PersistenceException + { + if (!_active) { + return null; + } + + YoBetaInfo proto = new YoBetaInfo(); + proto.userId = userId; + FieldMask mask = _table.getFieldMask(); + mask.setModified("userId"); + return loadByExample(_table, proto, mask); + } + + /** + * Deletes the yo beta info for the specified user id from the + * database. + */ + public void deleteYoBetaInfo (final int userId) + throws PersistenceException + { + if (!_active) { + return; + } + + executeUpdate(new Operation() { + public Void invoke (Connection conn, DatabaseLiaison liaison) + throws SQLException, PersistenceException + { + PreparedStatement stmt = null; + try { + stmt = conn.prepareStatement( + "delete from " + YOBETA_TABLE_NAME + " " + + "where USER_ID = ?"); + stmt.setInt(1, userId); + stmt.executeUpdate(); + + } finally { + JDBCUtil.close(stmt); + } + return null; + } + }); + } + + @Override + protected void createTables () + { + _table = new Table( + YoBetaInfo.class, YOBETA_TABLE_NAME, + YOBETA_TABLE_PRIMARY_KEY, true); + } + + /** A wrapper that provides access to the yo beta table. */ + protected Table _table; + + /** Whether or not we are actually in use. If our table doesn't exist, + * we quietly disable ourselves. */ + protected boolean _active; + + /** The name of the yobeta table. */ + protected static final String YOBETA_TABLE_NAME = "YOBETA"; + + /** The primary key for the yobeta table. */ + protected static final String YOBETA_TABLE_PRIMARY_KEY = "USER_ID"; +} diff --git a/src/main/java/com/threerings/user/depot/AccountActionRecord.java b/src/main/java/com/threerings/user/depot/AccountActionRecord.java new file mode 100644 index 0000000..180a6ad --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AccountActionRecord.java @@ -0,0 +1,97 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Timestamp; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.AccountAction; + +/** + * Emulates {@link AccountAction} for the Depot. + */ +@Entity(name="ACCOUNT_ACTIONS") +public class AccountActionRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = AccountActionRecord.class; + public static final ColumnExp ACTION_ID = colexp(_R, "actionId"); + public static final ColumnExp ACCOUNT_NAME = colexp(_R, "accountName"); + public static final ColumnExp ACTION = colexp(_R, "action"); + public static final ColumnExp DATA = colexp(_R, "data"); + public static final ColumnExp ENTERED = colexp(_R, "entered"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The action id. */ + @Id @Column(name="ACTION_ID") @GeneratedValue(strategy=GenerationType.AUTO) + public int actionId; + + /** The account name. */ + @Column(name="ACCOUNT_NAME") + public String accountName; + + /** The action that took place. */ + @Column(name="ACTION") + public int action; + + /** Data that is interpreted depending on the action type. */ + @Column(name="DATA", length=65536, nullable=true) + public String data; + + /** When the action took place. */ + @Column(name="ENTERED") + public Timestamp entered; + + /** + * Creates an AccountActionRecord from a AccountAction. + */ + public static AccountActionRecord fromAccountAction (AccountAction aa) + { + AccountActionRecord record = new AccountActionRecord(); + record.actionId = aa.actionId; + record.accountName = aa.accountName; + record.action = aa.action; + record.data = aa.data; + record.entered = aa.entered; + return record; + } + + /** + * Returns an AccountAction version of this record. + */ + public AccountAction toAccountAction () + { + AccountAction aa = new AccountAction(); + aa.actionId = actionId; + aa.accountName = accountName; + aa.action = action; + aa.data = data; + aa.entered = entered; + return aa; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link AccountActionRecord} + * with the supplied key values. + */ + public static Key getKey (int actionId) + { + return newKey(_R, actionId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(ACTION_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/AccountActionRepository.java b/src/main/java/com/threerings/user/depot/AccountActionRepository.java new file mode 100644 index 0000000..9311d63 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AccountActionRepository.java @@ -0,0 +1,274 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Timestamp; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.Ops; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.clause.Where; +import com.samskivert.jdbc.ConnectionProvider; + +import com.samskivert.util.StringUtil; + +import com.threerings.user.AccountAction; + +import static com.threerings.user.Log.log; + +/** + * Provides access to the account actions records. + */ +@Singleton +public class AccountActionRepository extends DepotRepository +{ + /** + * The database identifier used when establishing a database connection. + * This value being actiondb. + */ + public static final String ACTION_DB_IDENT = "actiondb"; + + @Inject public AccountActionRepository (PersistenceContext ctx) + { + super(ctx); + } + + public AccountActionRepository (ConnectionProvider conprov) + { + super(new PersistenceContext(ACTION_DB_IDENT, conprov, null)); + } + + /** + * Return the list of actions that have not yet been processed by the specified server. + * Returns all actions if no server name is specified. + * + * Note: this method has two side effects: the first time it is called, it will + * register this server as a action participant (assuming server is non-null) and + * subsequently, it will call {@link #pruneActions} if it has not done so within the last + * hour. + */ + public List getActions (String server) + { + return getActions(server, Integer.MAX_VALUE); + } + + /** + * Return the list of actions that have not yet been processed by the specified server. + * Returns all actions if no server name is specified. + * + * @param maxActions the maximum number of actions to return. + * + * Note: this method has two side effects: the first time it is called, it will + * register this server as a action participant (assuming server is non-null) and + * subsequently, it will call {@link #pruneActions} if it has not done so within the last + * hour. + */ + public List getActions (String server, int maxActions) + { + // TODO: Figure out how to do this in one query + List processedIds = from(ProcessedActionRecord.class) + .where(ProcessedActionRecord.SERVER.eq(server)) + .select(ProcessedActionRecord.ACTION_ID); + List actions = findAll(AccountActionRecord.class, + new Where(Ops.not(AccountActionRecord.ACTION_ID.in(processedIds)))); + + List list = Lists.newArrayListWithCapacity(actions.size()); + for (AccountActionRecord record : actions) { + list.add(record.toAccountAction()); + } + + if (StringUtil.isBlank(server)) { + return list; + } + + // if this is the first time this method is called, register ourselves + // with the action system + long now = System.currentTimeMillis(); + if (_lastActionPrune == 0L) { + _lastActionPrune = now; + registerActionServer(server); + + } else if (now - _lastActionPrune > ACTION_PRUNE_INTERVAL) { + _lastActionPrune = now; + pruneActions(); + } + return list; + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, int action) + { + addAction(accountName, null, action, ""); + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, String data, int action) + { + addAction(accountName, data, action, ""); + } + + /** + * Adds a new action to the repository. + */ + public void addAction (String accountName, int action, String server) + { + addAction(accountName, null, action, server); + } + + /** + * Adds a new action to the repository, with the specified server being marked as already + * having processed the action. + */ + public void addAction (String accountName, String data, int action, final String server) + { + AccountActionRecord record = new AccountActionRecord(); + record.accountName = accountName; + record.data = data; + record.action = action; + record.entered = new Timestamp(System.currentTimeMillis()); + insert(record); + + // note that it was processed by this server if appropriate + if (!StringUtil.isBlank(server)) { + noteProcessed(record.actionId, server); + } + } + + /** + * Updates the single specified action to reflect that the specified server has processed it. + */ + public void updateAction (AccountAction action, String server) + { + updateActions(Collections.singletonList(action), server); + } + + /** + * Batch update a list of actions. + */ + public void updateActions (List actions, String server) + { + for (int ii = 0, ll = actions.size(); ii < ll; ii++) { + noteProcessed(actions.get(ii).actionId, server); + } + } + + /** + * Deletes an action from the repository. + */ + public void deleteAction (AccountAction action) + { + delete(AccountActionRecord.fromAccountAction(action)); + deleteAll(ProcessedActionRecord.class, + new Where(ProcessedActionRecord.ACTION_ID.eq(action.actionId))); + } + + /** + * Notes that the specified server has processed the specified action. + */ + public void noteProcessed (int actionId, String server) + { + ProcessedActionRecord record = new ProcessedActionRecord(); + record.actionId = actionId; + record.server = server; + insert(record); + } + + /** + * Prunes actions that have been processed by all registered servers to keep the actions table + * small. This method need not be called by hand as it is called automatically by + * {@link #getActions(String,int)}, but no more frequently than once an hour. + */ + public void pruneActions () + { + Set servers = loadActionServers(); + Map> procmap = Maps.newHashMap(); + Set actids = Sets.newHashSet(); + + // determine which servers have processed which actions + for (ProcessedActionRecord record : findAll(ProcessedActionRecord.class)) { + Set procset = procmap.get(record.actionId); + if (procset == null) { + procmap.put(record.actionId, procset = Sets.newHashSet()); + } + procset.add(record.server); + } + + // determine which of those have been fully processed + for (Map.Entry> entry : procmap.entrySet()) { + if (entry.getValue().containsAll(servers)) { + actids.add(entry.getKey()); + } + } + + // now wipe out the actions (and processed entries for all actions that have been fully + // processed + if (actids.size() > 0) { + deleteAll(AccountActionRecord.class, + new Where(AccountActionRecord.ACTION_ID.in(actids))); + deleteAll(ProcessedActionRecord.class, + new Where(ProcessedActionRecord.ACTION_ID.in(actids))); + } + } + + /** + * Registers a server with the action system. This method is called automatically the first + * time {@link #getActions(String)} is called. + */ + protected void registerActionServer (String server) + { + // see if we're already registered + Set set = loadActionServers(); + if (set.contains(server)) { + return; + } + + ActionServerRecord record = new ActionServerRecord(); + record.server = server; + insert(record); + + log.info("Registered action server", "server", server); + } + + /** + * Loads up the set of action servers. + */ + protected Set loadActionServers () + { + Set set = Sets.newHashSet(); + for (ActionServerRecord record : findAll(ActionServerRecord.class)) { + set.add(record.server); + } + return set; + } + + @Override + protected void getManagedRecords (Set> classes) + { + classes.add(AccountActionRecord.class); + classes.add(ActionServerRecord.class); + classes.add(ProcessedActionRecord.class); + } + + /** Used by {@link #getActions(String)}. */ + protected long _lastActionPrune; + + /** We automatically prune the actions table once an hour. */ + protected static final long ACTION_PRUNE_INTERVAL = 60 * 60 * 1000L; +} diff --git a/src/main/java/com/threerings/user/depot/ActionServerRecord.java b/src/main/java/com/threerings/user/depot/ActionServerRecord.java new file mode 100644 index 0000000..e6fb2c7 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ActionServerRecord.java @@ -0,0 +1,43 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * A server that processes actions. + */ +@Entity(name="ACTION_SERVERS") +public class ActionServerRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = ActionServerRecord.class; + public static final ColumnExp SERVER = colexp(_R, "server"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The server name. */ + @Id @Column(name="SERVER") + public String server; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ActionServerRecord} + * with the supplied key values. + */ + public static Key getKey (String server) + { + return newKey(_R, server); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(SERVER); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/AffiliateCountRecord.java b/src/main/java/com/threerings/user/depot/AffiliateCountRecord.java new file mode 100644 index 0000000..381b915 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AffiliateCountRecord.java @@ -0,0 +1,30 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Computed; +import com.samskivert.depot.expression.ColumnExp; + +/** + * The number of users referred by an affiliate created on a particular day. + */ +@Computed(shadowOf=HistoricalUserRecord.class) +public class AffiliateCountRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = AffiliateCountRecord.class; + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp COUNT = colexp(_R, "count"); + // AUTO-GENERATED: FIELDS END + + /** The day that these users were created */ + public Date created; + + /** The number of users. */ + @Computed(fieldDefinition="count(*)") + public int count; +} diff --git a/src/main/java/com/threerings/user/depot/AffiliateInfo.java b/src/main/java/com/threerings/user/depot/AffiliateInfo.java new file mode 100644 index 0000000..4231f91 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AffiliateInfo.java @@ -0,0 +1,716 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import static com.threerings.user.Log.log; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.samskivert.servlet.SiteIdentifier; +import com.samskivert.servlet.util.CookieUtil; +import com.samskivert.servlet.util.ParameterUtil; +import com.samskivert.util.StringUtil; + +import com.threerings.user.AffiliateUtils; +import com.threerings.user.OOOUser; + +public abstract class AffiliateInfo +{ + /** The name of the session attribute used to hold the affiliate suffix */ + public static final String AFFILIATE_SUFFIX_ATTRIBUTE = + AffiliateInfo.class.getName() + ".AffiliateSuffix"; + + /** + * Class determining the behavior if the affiliate is 'new referral' i.e. the URL + * has a 'from' and optionally, a 'tag' parameter. + */ + protected static class NewReferral extends AffiliateInfo + { + protected final String fromParameter; + + protected final int siteId; + + protected final int tagId; + + public NewReferral (String fromParameter, int siteId, int tagId) + { + this.fromParameter = fromParameter; + this.siteId = siteId; + this.tagId = tagId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + if (isPersonalSuffix(fromParameter)) { + setAffiliateSuffix(req, fromParameter, tagId); + } else { + setAffiliateSuffix(req, "" + siteId, tagId); + } + } + + @Override + public String getAffiliateName () + { + return fromParameter; + } + + @Override + public boolean hasName () + { + return !isPersonalSuffix(fromParameter); + } + + @Override + public boolean hasSiteId () + { + return true; + } + + @Override + public int getSiteId () + { + return siteId; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, "" + tagId); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + if (isPersonalSuffix(fromParameter)) { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, fromParameter); + } else { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + siteId); + } + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO probably need nothing + giveCookie(req, rsp, SITE_REFER_COOKIE, fromParameter); + } + + @Override + public int getTagId () + { + return tagId; + } + } + + /** + * Class determining the behavior if the wasn't referred to us in this particular + * request, but they were referred to us before, and we've recorded that in cookies. + */ + protected static class CurrentCookies extends AffiliateInfo + { + protected final String siteIdCookieValue; + + protected final int tagIdCookieValue; + + public CurrentCookies (String siteIdCookieValue, int tagIdCookieValue) + { + this.siteIdCookieValue = siteIdCookieValue; + this.tagIdCookieValue = tagIdCookieValue; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, siteIdCookieValue, tagIdCookieValue); + } + + @Override + public String getAffiliateName () + { + return null; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public boolean hasSiteId () + { + return true; + } + + @Override + public int getSiteId () + { + try { + return Integer.parseInt(siteIdCookieValue); + } catch (NumberFormatException nfe) { + return 0; + } + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, "" + tagIdCookieValue); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, siteIdCookieValue); + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, SITE_REFER_COOKIE, ""); + } + + @Override + public int getTagId () + { + return tagIdCookieValue; + } + } + + /** + * Class determining the behavior if the user has an 'old-style' cookie indicating + * that they came to us as a referral. + */ + protected static class LegacyCookies extends AffiliateInfo + { + protected final String siteRefer; + + protected final int siteId; + + public LegacyCookies (int siteId, String siteReferCookie) + { + this.siteRefer = siteReferCookie; + this.siteId = siteId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + if (isPersonalSuffix(siteRefer)) { + setAffiliateSuffix(req, siteRefer, AffiliateTagRecord.NO_TAG); + } else { + setAffiliateSuffix(req, "" + siteId, AffiliateTagRecord.NO_TAG); + } + } + + @Override + public String getAffiliateName () + { + if (!isPersonalSuffix(siteRefer)) { + return siteRefer; + } else { + return ""; + } + } + + @Override + public boolean hasName () + { + return !isPersonalSuffix(siteRefer); + } + + @Override + public boolean hasSiteId () + { + return true; + } + + @Override + public int getSiteId () + { + return siteId; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO: should we actually be removing the cookie here + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, ""); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + if (isPersonalSuffix(siteRefer)) { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, siteRefer); + } else { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + siteId); + } + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // TODO: should be be removing this cookie? + giveCookie(req, rsp, SITE_REFER_COOKIE, siteRefer); + } + + @Override + public int getTagId () + { + return AffiliateTagRecord.NO_TAG; + } + } + + /** + * Define the behavior if we don't have any affiliate information but we want to track the user + * in a separate category from the 'default site'. + */ + protected static class AlternateDefault extends AffiliateInfo + { + protected final int site; + + protected AlternateDefault (int site) + { + this.site = site; + } + + @Override + public boolean isDefaultAffiliate () + { + return true; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, "" + site, AffiliateTagRecord.NO_TAG); + } + + @Override + public String getAffiliateName () + { + return ""; + } + + @Override + public int getTagId () + { + return AffiliateTagRecord.NO_TAG; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public boolean hasSiteId () + { + return true; + } + + @Override + public int getSiteId () + { + return site; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // Don't issue a cookie; + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_ID_COOKIE, "" + site); + } + + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // Don't issue a cookie; + } + } + + /** + * Determines the behavior if a tag cookie exists, but not an affiliate (site id) cookie. + * "0" will be supplied as the affiliate to the 'affsuf' context attribute. + */ + protected static class TagOnly extends AffiliateInfo + { + protected final int tagId; + + public TagOnly (int tagId) + { + this.tagId = tagId; + } + + @Override + public void addToContext (HttpServletRequest req) + { + setAffiliateSuffix(req, "0", tagId); + } + + @Override + public String getAffiliateName () + { + return ""; + } + + @Override + public boolean hasName () + { + return false; + } + + @Override + public boolean hasSiteId () + { + return false; + } + + @Override + public int getSiteId () + { + return 0; + } + + @Override + public void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp) + { + giveCookie(req, rsp, AffiliateUtils.AFFILIATE_TAG_COOKIE, String.valueOf(tagId)); + } + + @Override + public void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // do not issue a cookie + } + + @Override + public void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp) + { + // do not issue a cookie + } + + @Override + public int getTagId () + { + return tagId; + } + } + + /** + * Find out whether we know the name of the affiliate. + */ + public abstract boolean hasName (); + + /** + * Get the name of the affiliate. + * + * @return - The name as a string. + */ + public abstract String getAffiliateName (); + + /** + * Find out whether we know the site id of the affiliate + */ + public abstract boolean hasSiteId (); + + /** + * Get the site id of the affiliate. + * + * @return - the site id of the affiliate + */ + public abstract int getSiteId (); + + /** + * Get the id for the affiliate tag. + */ + public abstract int getTagId (); + + /** + * Compute the appropriate affiliate 'affsuf' and add it to the session along with the + * 'affid' used by some of the web templates. Now with less Velocity. + */ + public abstract void addToContext (HttpServletRequest req); + + /** + * Issue the site_id cookie to the browser (non-velocity). + */ + public abstract void issueSiteIdCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Issue the site_refer cookie to the browser. + */ + public abstract void issueSiteReferCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Issue the affiliate_tag cookie to the browser (non-velocity). + */ + public abstract void issueAffiliateTagCookie (HttpServletRequest req, HttpServletResponse rsp); + + /** + * Normally returns false, but can be overridden to indicate that we've been given + * a cookie with a default affiliate passed in to us. "Default" isn't quite the right word + * since this is really used for letting pages hardwire their default affiliate to be something + * other than the default, but everything else uses this word, and I don't have a better idea + * offhand. + */ + public boolean isDefaultAffiliate () + { + return false; + } + + /** + * Make an affiliate info object from the cookies and parameters in the request. + */ + public static AffiliateInfo getAffiliateInfo ( + DepotUserManager usermgr, ReferralRepository repository, SiteIdentifier identifier, + HttpServletRequest req, boolean parseFromParameter) + { + int siteId = identifier.identifySite(req); + return getAffiliateInfo(usermgr, repository, identifier, req, siteId, parseFromParameter); + } + + /** + * Make an affiliate info object from the cookies and parameters in the request. + * + * @param alternateDefault - override default affiliate (hard-coded into affiliate pages) + * @param parseFromParameter - Should we look at the request parameter "from"? + * @return - The affiliate info or null if there wasn't enough information in the request to + * construct it. + */ + public static AffiliateInfo getAffiliateInfo ( + DepotUserManager usermgr, ReferralRepository repository, SiteIdentifier identifier, + HttpServletRequest req, int alternateDefault, boolean parseFromParameter) + { + // if they're already a user with us, bail + if (null != usermgr.loadUser(req)) { + return null; + } + + String fromParameter = parseFromParameter ? parseFromParameter(req) : ""; + + // is this a new referral - i.e. this request was referred from a referral link? + if (!StringUtil.isBlank(fromParameter)) { + String tagParameter = readAffiliateTag(req); + + int siteId = parseReferrer(repository, identifier, fromParameter); + if (siteId == 0) { + log.warning("User referred by bogus referrer [referrer=" + fromParameter + "]."); + return null; + } + + if (!StringUtil.isBlank(tagParameter) && !isPersonalSuffix(fromParameter)) { + int tagId = usermgr.getAffiliateTagId(tagParameter); + + return new NewReferral(fromParameter, siteId, tagId); + } else { + return new NewReferral(fromParameter, siteId, AffiliateTagRecord.NO_TAG); + } + } + + // do we have modern cookies for this referral? + String siteIdCookieValue = readSiteIdCookie(req); + if (!StringUtil.isBlank(siteIdCookieValue)) { + int tagIdCookieValue = readTagId(req); + + return new CurrentCookies(siteIdCookieValue, tagIdCookieValue); + } + + // do we have old fashioned cookies for this referral? + String siteReferCookie = CookieUtil.getCookieValue(req, SITE_REFER_COOKIE); + if (!StringUtil.isBlank(siteReferCookie)) { + int siteId = parseReferrer(repository, identifier, siteReferCookie); + return new LegacyCookies(siteId, siteReferCookie); + } + + // we have no useful affiliate information. If we have been provided with an alternative + // default site, then use that as the site-id. + if (alternateDefault != identifier.identifySite(req)) { + return new AlternateDefault(alternateDefault); + } + + // we have a tag cookie but no affiliate cookie + int tagIdCookieValue = readTagId(req); + if (tagIdCookieValue != AffiliateTagRecord.NO_TAG) { + return new TagOnly(tagIdCookieValue); + } + + // we couldn't figure out anything useful to do with whatever affiliate related info + // we had, so we return null and the caller can do nothing. + return null; + } + + /** + * @param repository For verifying referrer ids against the database + * @param identifier Velocity site repository + * @param referrer Referral request string, may be a personal referrer of + * the format r[0-9]+ or user-[0-9]+, or a site referrer of format [0-9]+ + * @return the referrer siteId, which may be a positive or negative number, + * or 0 if no match. + */ + public static int parseReferrer (ReferralRepository repository, SiteIdentifier identifier, + String referrer) + { + // blank referrer string + if (StringUtil.isBlank(referrer)) { + return 0; + } + + // handle new-style personal referrals + if (referrer.matches("r[0-9]+")) { + int refId = 0; + try { + refId = Integer.parseInt(referrer.substring(1)); + } catch (NumberFormatException nfe) { + log.warning("Bogus user referral: " + referrer); + return 0; + } + + // look up the record in the referrer repository + ReferralRecord rec = repository.lookupReferral(refId); + return (rec == null) ? OOOUser.DEFAULT_SITE_ID : (-1 * rec.referrerId); + } + + // handle old-style personal referrals + if (referrer.startsWith("user-")) { + try { + return -1 * Integer.parseInt(referrer.substring(5)); + } catch (NumberFormatException nfe) { + log.warning("Bogus user referral: " + referrer); + return 0; + } + } + + // check referrer string against site repository + int site = identifier.getSiteId(referrer); + if (site != -1) { + return site; + } + log.warning("Bogus site specified in referrer " + "[value=" + referrer + "]."); + return 0; + } + + /** + * Parse the contents of the 'from' parameter. + */ + public static String parseFromParameter (HttpServletRequest req) + { + String value = ParameterUtil.getParameter(req, "from", true); + if (value != null) { + // strip the crap some affiliates append to their ID + Matcher am = _affregex.matcher(value); + if (am.find()) { + value = am.group(); + } + } + + return value; + } + + /** + * Find out whether the affiliate name is actually a personal referral as + * opposed to a site referral. + * + * @return - True if the affiliate name is a personal referral. False if + * it's a site or unknown. + */ + protected static boolean isPersonalSuffix (String name) + { + return name != null && name.matches("r[0-9]+"); + } + + /** + * Get the value stored in the site id cookie. + */ + protected static String readSiteIdCookie (HttpServletRequest req) + { + return CookieUtil.getCookieValue(req, AffiliateUtils.AFFILIATE_ID_COOKIE); + } + + /** + * Calculate and add the affiliate id and affiliate suffix to the session. No longer + * added to the Velocity context; this is velocity-independant. + * @param req + * @param referrer + * @param tagId + */ + protected void setAffiliateSuffix (HttpServletRequest req, String referrer, int tagId) + { + if (tagId != AffiliateTagRecord.NO_TAG) { + req.getSession().setAttribute(AFFILIATE_SUFFIX_ATTRIBUTE, "-" + referrer + "-" + tagId); + } else { + req.getSession().setAttribute(AFFILIATE_SUFFIX_ATTRIBUTE, "-" + referrer); + } + + req.getSession().setAttribute(AffiliateUtils.AFFILIATE_ID_ATTRIBUTE, referrer); + } + + /** + * Add a cookie to the response (velocity independant). + * @param req servlet request object + * @param rsp sevlet response object + * @param name cookie identifier + * @param value value for the cookie + */ + protected static void giveCookie (HttpServletRequest req, HttpServletResponse rsp, + String name, String value) + { + Cookie cookie = new Cookie(name, value); + // cookie expires in one month + cookie.setMaxAge(30 * 24 * 60 * 60); + // set the path and widened domain (eg www.domain.com -> .domain.com) of the cookie + cookie.setPath("/"); + CookieUtil.widenDomain(req, cookie); + rsp.addCookie(cookie); + } + + /** + * Get the affiliate tag from the tag parameter in the request. + * + * @param req - The request. + * @return - The tag string. + */ + protected static String readAffiliateTag (HttpServletRequest req) + { + return req.getParameter("tag"); + } + + protected static int readTagId (HttpServletRequest req) + { + // if they have an affiliate tag cookie, get it. + String cookie = CookieUtil.getCookieValue(req, AffiliateUtils.AFFILIATE_TAG_COOKIE); + if (cookie != null) { + try { + return Integer.parseInt(cookie); + } catch (NumberFormatException e) { + return AffiliateTagRecord.NO_TAG; + } + } else { + return AffiliateTagRecord.NO_TAG; + } + } + + /** A regexp that matches just the proper parts of an affiliate id. */ + protected static Pattern _affregex = Pattern.compile(OOOUser.SITE_STRING_REGEX); + + /** + * The name of the legacy cookie used to store the affiliate name passed by + * them to us with their original request. + */ + protected static final String SITE_REFER_COOKIE = "site_refer"; +} diff --git a/src/main/java/com/threerings/user/depot/AffiliateTagRecord.java b/src/main/java/com/threerings/user/depot/AffiliateTagRecord.java new file mode 100644 index 0000000..af56c4e --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AffiliateTagRecord.java @@ -0,0 +1,53 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Maintains a mapping from an arbitrary text tag to an integer identifier. + */ +@Entity(name="afftags") +public class AffiliateTagRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = AffiliateTagRecord.class; + public static final ColumnExp TAG_ID = colexp(_R, "tagId"); + public static final ColumnExp TAG = colexp(_R, "tag"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** Value indicating that the affiliate did not provide a tag **/ + public static final int NO_TAG = 0; + + /** The automatically generated tag id. */ + @Id @GeneratedValue(strategy=GenerationType.AUTO) + public int tagId; + + /** The arbitrary text tag. */ + @Column(unique=true) + public String tag; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link AffiliateTagRecord} + * with the supplied key values. + */ + public static Key getKey (int tagId) + { + return newKey(_R, tagId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(TAG_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/AffiliateUserCountRecord.java b/src/main/java/com/threerings/user/depot/AffiliateUserCountRecord.java new file mode 100644 index 0000000..880be88 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/AffiliateUserCountRecord.java @@ -0,0 +1,25 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Computed; +import com.samskivert.depot.expression.ColumnExp; + +@Computed(shadowOf=HistoricalUserRecord.class) +public class AffiliateUserCountRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = AffiliateUserCountRecord.class; + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + public static final ColumnExp COUNT = colexp(_R, "count"); + // AUTO-GENERATED: FIELDS END + + /** The affiliate site ID. */ + public int siteId; + + /** The number of users. */ + @Computed(fieldDefinition="count(*)") + public int count; +} diff --git a/src/main/java/com/threerings/user/depot/BannedIdentRecord.java b/src/main/java/com/threerings/user/depot/BannedIdentRecord.java new file mode 100644 index 0000000..7c1b738 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/BannedIdentRecord.java @@ -0,0 +1,60 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Represents a row in the BANNED_IDENTS table, Depot version. + */ +@Entity(name="BANNED_IDENTS") +public class BannedIdentRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = BannedIdentRecord.class; + public static final ColumnExp MACH_IDENT = colexp(_R, "machIdent"); + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** a 'unique' id for the specific machine we have seen. */ + @Id @Column(name="MACH_IDENT") + public String machIdent; + + /** The site if which this machine is banned from. */ + @Id @Column(name="SITE_ID") + public int siteId; + + /** Blank constructor for the unserialization business. */ + public BannedIdentRecord () + { + } + + /** A constructor that populates this record. */ + public BannedIdentRecord (String machIdent, int siteId) + { + this.machIdent = machIdent; + this.siteId = siteId; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link BannedIdentRecord} + * with the supplied key values. + */ + public static Key getKey (String machIdent, int siteId) + { + return newKey(_R, machIdent, siteId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(MACH_IDENT, SITE_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/ConversionRecord.java b/src/main/java/com/threerings/user/depot/ConversionRecord.java new file mode 100644 index 0000000..5d58098 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ConversionRecord.java @@ -0,0 +1,99 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Timestamp; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.util.StringUtil; + +import com.threerings.user.OOOUser; + +/** + * Emulates {@link ConversionRecord} for the Depot. + */ +@Entity(name="CONVERSION") +public class ConversionRecord extends PersistentRecord +{ + /** Indicates that an account was created. */ + public static final int CREATED = 1; + + /** Indicates that an account that was not currently paying us started + * to. This won't be true before 2005/03/01 where we tried (more or + * less) to save everytime someone gave us money. */ + public static final int SUBSCRIPTION_START = 2; + + /** Indicates that a subscription was canceled or lapsed from use. */ + public static final int SUBSCRIPTION_ENDED = 3; + + // AUTO-GENERATED: FIELDS START + public static final Class _R = ConversionRecord.class; + public static final ColumnExp RECORDED = colexp(_R, "recorded"); + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + public static final ColumnExp ACTION = colexp(_R, "action"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The timestamp on which the action was taken. */ + @Id @Column(name="RECORDED") + public Timestamp recorded; + + /** The unique identifier of the user that took an action. */ + @Column(name="USER_ID") + public int userId; + + /** + * The partner with whom the user is associated. You should always + * use the accessor method to access this value in order to correctly + * deal with personal referrals. + */ + @Column(name="SITE_ID") + public int siteId; + + /** The identifier indicating the action taken. */ + @Column(name="ACTION") + public short action; + + /** + * Returns the site id associated with this record, properly mapping + * negative ids (personal referrals) into a single category. + */ + public int getSiteId () + { + return (siteId <= 0) ? OOOUser.REFERRAL_SITE_ID : siteId; + } + + @Override + public String toString () + { + return StringUtil.fieldsToString(this); + } + + public boolean equals (ConversionRecord cr) + { + return (cr.userId == userId && cr.siteId == siteId && + cr.action == action && cr.recorded.equals(recorded)); + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ConversionRecord} + * with the supplied key values. + */ + public static Key getKey (Timestamp recorded) + { + return newKey(_R, recorded); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(RECORDED); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/ConversionRepository.java b/src/main/java/com/threerings/user/depot/ConversionRepository.java new file mode 100644 index 0000000..1fbd6dc --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ConversionRepository.java @@ -0,0 +1,234 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Timestamp; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.clause.OrderBy; +import com.samskivert.depot.clause.Where; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.util.IntIntMap; +import com.samskivert.util.Calendars; + +/** + * Provides an interface to the conversion repository. A service that + * makes use of the OOO user management and billing system can make use of + * this conversion service to track conversion related information for + * their users. + */ +@Singleton +public class ConversionRepository extends DepotRepository +{ + /** + * The database identifier used when establishing a database + * connection. This value being conversiondb. + */ + public static final String CONVERSION_DB_IDENT = "conversiondb"; + + @Inject public ConversionRepository (PersistenceContext ctx) + { + super(ctx); + } + + /** + * Creates the repository and prepares it for operation. + * + * @param conprov the database connection provider. + */ + public ConversionRepository (ConnectionProvider conprov) + { + super(new PersistenceContext(CONVERSION_DB_IDENT, conprov, null)); + } + + /** + * Records an action in the conversion table. + */ + public void recordAction (int userId, int siteId, int action) + { + recordAction(userId, siteId, action, new Timestamp(System.currentTimeMillis())); + } + + /** + * Records an action in the conversion table. + */ + public void recordAction (int userId, int siteId, int action, Timestamp recorded) + { + ConversionRecord record = new ConversionRecord(); + record.userId = userId; + record.siteId = siteId; + record.action = (short)action; + record.recorded = recorded; + insert(record); + } + + /** + * Get the subscriber info for a given date broken up by site. Key 0 + * references a total for all sites. + */ + public IntIntMap getSiteSubscribers (java.util.Date date) + { + checkRecomputeSubInfo(); + return _subscribers.get(date); + } + + /** + * Return the total number of subscribers on then given date. + */ + public int getTotalSubscribers (java.util.Date date) + { + checkRecomputeSubInfo(); + return _subscribers.get(date).get(0); + } + + /** + * Returns all of our subscription info. Date -> IntIntMap, + * IntIntMap: SiteId -> Subscribers. SiteId 0 is a total of all sites. + */ + public Map getSubscriptionInfo () + { + checkRecomputeSubInfo(); + return _subscribers; + } + + /** + * Recompute our subscription info if it is older than the cache time. + */ + protected void checkRecomputeSubInfo () + { + if (_subTime + SUB_CACHE_TIME < System.currentTimeMillis()) { + computeSubscriptionInfo(); + } + } + + /** + * Build up all the subscriber data over time and affiliate. + */ + protected void computeSubscriptionInfo () + { + // get all the raw data from the db, ordered by date + List data = findAll(ConversionRecord.class, + new Where(ConversionRecord.ACTION.in(ConversionRecord.SUBSCRIPTION_START, + ConversionRecord.SUBSCRIPTION_ENDED)), + OrderBy.ascending(ConversionRecord.RECORDED)); + + java.util.Date recent = null; + + Set subs = Sets.newHashSet(); + Map> siteSubs = Maps.newHashMap(); + + // these are used to do the right thing if we get both a subscribe + // and unsubscribe action in the same day, but in the wrong order + Set subexcept = Sets.newHashSet(); + Set unsubexcept = Sets.newHashSet(); + + for (ConversionRecord cr : data) { + // we only care about the date, not the time, at which the + // entry was recorded since we are hashing by day + java.util.Date day = Calendars.at(cr.recorded).zeroTime().toDate(); + + // if the date changes, store the data for the most recent day + if (recent == null) { + recent = day; + } else if (!recent.equals(day)) { + subexcept.clear(); + unsubexcept.clear(); + addSubscriptionEntry(recent, subs, siteSubs); + recent = day; + } + + // make sure we have a sitewise set of subscribers + if (!siteSubs.containsKey(cr.getSiteId())) { + siteSubs.put(cr.getSiteId(), Sets.newHashSet()); + } + Set siteMap = siteSubs.get(cr.getSiteId()); + + // update our data + if (cr.action == ConversionRecord.SUBSCRIPTION_START) { + // handle not-subscribed -> unsubscribe -> subscribe + if (!checkExcept(unsubexcept, subexcept, + subs.contains(cr.userId), cr)) { + subs.add(cr.userId); + siteMap.add(cr.userId); + } + + } else if (cr.action == ConversionRecord.SUBSCRIPTION_ENDED) { + // handle is-subscribed -> subscribe -> unsubscribe + if (!checkExcept(subexcept, unsubexcept, + !subs.contains(cr.userId), cr)) { + subs.remove(cr.userId); + siteMap.remove(cr.userId); + } + } + } + + // add in the incomplete current day info + addSubscriptionEntry(Calendars.now().zeroTime().toDate(), subs, siteSubs); + + // and mark when we finished + _subTime = System.currentTimeMillis(); + } + + protected boolean checkExcept ( + Set expect, Set surprise, boolean currentStatus, ConversionRecord cr) + { + // first check to see if he's in the opposite exception set; in + // which case we clear him out and ignore this action + if (expect.remove(cr.userId)) { + return true; + } + + // next, check to see if he's already in the expected state, in which + // case we put him into the exception set + return currentStatus; + } + + /** + * Add an entry to the _subscriptions data for the given day. + */ + protected void addSubscriptionEntry ( + java.util.Date day, Set subs, Map> siteSubs) + { + IntIntMap daysubs = new IntIntMap(); + for (Map.Entry> entry : siteSubs.entrySet()) { + Set set = entry.getValue(); + int key = entry.getKey().intValue(); + daysubs.put(key, set.size()); + } + daysubs.put(0, subs.size()); + _subscribers.put(day, daysubs); + } + + @Override + protected void getManagedRecords (Set> classes) + { + classes.add(ConversionRecord.class); + } + + /** + * Contains subscription info about our users over time. Keys are + * dates and the values are IntIntMaps mapping siteId -> subsrivers + * with 0 holding the total for all sites. + */ + protected Map _subscribers = Maps.newHashMap(); + + /** The time at which we last computed our subscriber info. */ + protected long _subTime; + + /** The time in millis to cache our subscriber info before recomputing. */ + protected long SUB_CACHE_TIME = 1l * 60l * 60l * 1000l; +} diff --git a/src/main/java/com/threerings/user/depot/DepotUserManager.java b/src/main/java/com/threerings/user/depot/DepotUserManager.java new file mode 100644 index 0000000..d70eddc --- /dev/null +++ b/src/main/java/com/threerings/user/depot/DepotUserManager.java @@ -0,0 +1,536 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.util.Map; +import java.util.Properties; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.common.collect.Maps; + +import com.samskivert.depot.DatabaseException; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.jdbc.ConnectionProvider; +import com.samskivert.servlet.RedirectException; +import com.samskivert.servlet.user.AuthenticationFailedException; +import com.samskivert.servlet.user.Authenticator; +import com.samskivert.servlet.user.InvalidPasswordException; +import com.samskivert.servlet.user.NoSuchUserException; +import com.samskivert.servlet.user.Password; +import com.samskivert.servlet.user.User; +import com.samskivert.servlet.util.CookieUtil; +import com.samskivert.servlet.util.RequestUtils; +import com.samskivert.util.Interval; +import com.samskivert.util.RunQueue; +import com.samskivert.util.StringUtil; +import com.samskivert.util.Tuple; + +import com.threerings.user.OOOUser; + +import static com.threerings.user.Log.log; + +/** + * A OOOUserManager using a depot repository. + */ +public class DepotUserManager +{ + /** An instance of the insecure authenticator for general-purpose use. */ + public static final Authenticator AUTH_INSECURE = new InsecureAuthenticator(); + + /** An instance of the password authenticator for general-purpose use. */ + public static final Authenticator AUTH_PASSWORD = new PasswordAuthenticator(); + + /** + * A totally insecure authenticator that authenticates any user. Note: Applications + * that make use of this authenticator should make sure the user has already been authenticated + * through some other means. + */ + public static class InsecureAuthenticator implements Authenticator + { + // documentation inherited + public void authenticateUser (User user, String username, Password password) + throws InvalidPasswordException + { + // don't care + } + } + + /** + * An authenticator that requires that the user-supplied password match the actual user + * password. + */ + public static class PasswordAuthenticator implements Authenticator + { + // documentation inherited + public void authenticateUser (User user, String username, Password password) + throws AuthenticationFailedException + { + if (!user.passwordsMatch(password)) { + throw new InvalidPasswordException("error.invalid_password"); + } + } + } + + /** + * Creates our Depot user manager and prepares it for operation. + */ + public DepotUserManager (Properties config, ConnectionProvider conprov) + throws DatabaseException + { + this(config, new PersistenceContext("userdb", conprov, null)); + } + + /** + * Creates our Depot user manager and prepares it for operation. + */ + public DepotUserManager (Properties config, PersistenceContext pctx) + throws DatabaseException + { + this(config, pctx, null); + } + + /** + * Creates our Depot user manager and prepares it for operation. + */ + public DepotUserManager (Properties config, PersistenceContext pctx, RunQueue pruneQueue) + throws DatabaseException + { + init(config, pctx, pruneQueue); + } + + /** + * Creates a depot user manager which must subsequently be initialized with a call to + * {@link #init}. + */ + public DepotUserManager () + { + } + + /** + * Prepares this user manager for operation. Presently the user manager requires the + * following configuration information: + * + *
    + *
  • login_url: Should be set to the URL to which to redirect a requester if + * they are required to login before accessing the requested page. For example: + * + *
    +     * login_url = /usermgmt/login.ajsp?return=%R
    +     * 
    + * + * The %R will be replaced with the URL encoded URL the user is currently + * requesting (complete with query parameters) so that the login code can redirect the user + * back to this request once they are authenticated. + *
+ * + * @param config the user manager configuration properties. + * @param pctx the persistence context + */ + public void init (Properties config, PersistenceContext pctx) + throws DatabaseException + { + init(config, pctx, null); + } + + /** + * Prepares this user manager for operation. See {@link #init(Properties,PersistenceContext)}. + * + * @param pruneQueue an optional run queue on which to run our periodic session pruning task. + */ + public void init (Properties config, PersistenceContext pctx, RunQueue pruneQueue) + throws DatabaseException + { + // save this for later + _config = config; + + // create the user repository + _repository = createRepository(pctx); + + // fetch the login URL from the properties + _loginURL = config.getProperty("login_url"); + if (_loginURL == null) { + log.warning("No login_url supplied in user manager config. Authentication won't work."); + _loginURL = "/missing_login_url"; + } + + // look up any override to our user auth cookie + String authCook = config.getProperty("auth_cookie.name"); + if (!StringUtil.isBlank(authCook)) { + _userAuthCookie = authCook; + } + + if (USERMGR_DEBUG) { + log.info("UserManager initialized", "acook", _userAuthCookie, "login", _loginURL); + } + + // register a cron job to prune the session table every hour + _pruner = new Interval() { + @Override public void expired () { + _repository.pruneSessions(); + } + }; + if (pruneQueue != null) { + _pruner.setRunQueue(pruneQueue); + } + _pruner.schedule(SESSION_PRUNE_INTERVAL, true); + + // look up the access denied URL + _accessDeniedURL = config.getProperty("access_denied_url"); + if (_accessDeniedURL == null) { + log.warning("No 'access_denied_url' supplied in user manager config. " + + "Restricted pages will behave strangely."); + } + + // load up our affiliate tag mappings + for (AffiliateTagRecord sub : getRepository().loadAffiliateTags()) { + _tagMap.put(sub.tag, sub.tagId); + } + } + + public void shutdown () + { + // cancel our session table pruning thread + _pruner.cancel(); + } + + /** + * Returns a reference to the repository in use by this user manager. + */ + public DepotUserRepository getRepository () + { + return _repository; + } + + /** + * Returns the id to which the specified tag has been mapped, assigning a new tag id if + * necessary. + * + * @return -1 if an error occurred assigning a new id. + */ + public int getAffiliateTagId (String tag) + { + // if we've already mapped this value, we're good to go + Integer tagId = _tagMap.get(tag); + if (tagId != null) { + return tagId; + } + + // register a new affiliate tag and add it to our mappings + tagId = getRepository().registerAffiliateTag(tag); + _tagMap.put(tag, tagId); + return tagId; + } + + /** + * Both tag strings and ids are unique, so grab the string (key) based on the id (value) + * @param tagId ID to search for (eg 45) + * @return String value of the tag or tags (eg "nov07GroupA::DF34A9") + */ + public String getAffiliateTagString (int tagId) + { + Integer theTagId = Integer.valueOf(tagId); + + for (Map.Entry entry : _tagMap.entrySet()) { + if (theTagId.equals(entry.getValue())) { + return entry.getKey(); + } + } + return null; + } + + /** + * Returns the authentication token for the given request + */ + public String getAuthToken (HttpServletRequest req) + { + return CookieUtil.getCookieValue(req, _userAuthCookie); + } + + /** + * Fetches the necessary authentication information from the http request and loads the user + * identified by that information. + * + * @return the user associated with the request or null if no user was associated with the + * request or if the authentication information is bogus. + */ + public OOOUser loadUser (HttpServletRequest req) + { + String authcook = getAuthToken(req); + if (USERMGR_DEBUG) { + log.info("Loading user by cookie", _userAuthCookie, authcook); + } + return loadUser(authcook); + } + + /** + * Loads up a user based on the supplied session authentication token. + */ + public OOOUser loadUser (String authcode) + { + OOOUser user = (authcode == null) ? null : _repository.loadUserBySession(authcode, false); + if (USERMGR_DEBUG) { + log.info("Loaded user by authcode", "code", authcode, "user", user); + } + return user; + } + + /** + * Fetches the necessary authentication information from the http request and loads the user + * identified by that information. If no user could be loaded (because the requester is not + * authenticated), a redirect exception will be thrown to redirect the user to the login page + * specified in the user manager configuration. + * + * @return the user associated with the request. + */ + public OOOUser requireUser (HttpServletRequest req) + throws RedirectException + { + OOOUser user = loadUser(req); + // if no user was loaded, we need to redirect these fine people to the login page + if (user == null) { + String eurl = RequestUtils.getLocationEncoded(req); + String target = _loginURL.replace("%R", eurl); + if (USERMGR_DEBUG) { + log.info("No user found in require, redirecting", "to", target); + } + throw new RedirectException(target); + } + return user; + } + + /** + * Extends the standard {@link #requireUser(HttpServletRequest)} with + * the additional requirement that the user hold the specified token. + * If they do not, they will be redirected to a page informing them + * access is denied. + * + * @return the user associated with the request. + */ + public OOOUser requireUser (HttpServletRequest req, byte token) + throws RedirectException + { + OOOUser user = requireUser(req); + if (!user.holdsToken(token)) { + throw new RedirectException(_accessDeniedURL); + } + return user; + } + + /** + * Extends the standard {@link #requireUser(HttpServletRequest)} with + * the additional requirement that the user hold one of the specified + * tokens. If they do not, they will be redirected to a page informing + * them access is denied. + * + * @return the user associated with the request. + */ + public OOOUser requireUser (HttpServletRequest req, byte[] tokens) + throws RedirectException + { + OOOUser user = requireUser(req); + if (!user.holdsAnyToken(tokens)) { + throw new RedirectException(_accessDeniedURL); + } + return user; + } + + /** + * Attempts to authenticate the requester and initiate an authenticated session for them. An + * authenticated session involves their receiving a cookie that proves them to be authenticated + * and an entry in the session database being created that maps their information to their + * userid. If this call completes, the session was established and the proper cookies were set + * in the supplied response object. If invalid authentication information is provided or some + * other error occurs, an exception will be thrown. + * + * @param username The username supplied by the user. + * @param password The password supplied by the user. + * @param persist If true, the cookie will expire in one month, if false, the cookie will + * expire at the end of the user's browser session. + * @param req The request via which the login page was loaded. + * @param rsp The response in which the cookie is to be set. + * @param auth The authenticator used to check whether the user should be authenticated. + * + * @return the user object of the authenticated user. + */ + public OOOUser login (String username, Password password, boolean persist, + HttpServletRequest req, HttpServletResponse rsp, Authenticator auth) + throws AuthenticationFailedException + { + // load up the requested user + OOOUser user = _repository.loadUser(username); + if (user == null) { + throw new NoSuchUserException("error.no_such_user"); + } + + // run the user through the authentication gamut + auth.authenticateUser(user, username, password); + + // give them the necessary cookies and business + effectLogin(user, persist, req, rsp); + + return user; + } + + /** + * Attempts to authenticate the requester and initiate an authenticated session for them. A + * session token will be assigned to the user and returned along with the associated {@link + * User} record. It is assumed that the client will maintain the session token via its own + * means. + * + * @param username the username supplied by the user. + * @param password the password supplied by the user. + * @param expires the number of days in which this session should expire. + * @param auth the authenticator used to check whether the user should be authenticated. + * + * @return the user object of the authenticated user. + */ + public Tuple login ( + String username, Password password, int expires, Authenticator auth) + throws AuthenticationFailedException + { + // load up the requested user + OOOUser user = _repository.loadUser(username); + if (user == null) { + throw new NoSuchUserException("error.no_such_user"); + } + + // run the user through the authentication gamut + auth.authenticateUser(user, username, password); + + // register a session for this user + String authcode = _repository.registerSession(user, expires); + if (USERMGR_DEBUG) { + log.info("Session started", "user", username, "code", authcode); + } + return new Tuple(user, authcode); + } + + /** + * If a user is already known to be authenticated for one reason or other, this method can be + * used to give them the appropriate authentication cookies to effect their login. + * + * @param persist If true, the cookie will expire in one month, if false, the cookie will + * expire at the end of the user's browser session. + * @return The registered session authcode + */ + public String effectLogin ( + OOOUser user, boolean persist, HttpServletRequest req, HttpServletResponse rsp) + { + return effectLogin(user, persist ? PERSIST_EXPIRE_DAYS : NON_PERSIST_EXPIRE_DAYS, req, rsp); + } + + /** + * If a user is already known to be authenticated for one reason or other, this method can be + * used to give them the appropriate authentication cookies to effect their login. + * + * @param expires the number of days in which to expire the session cookie, 0 means expire at + * the end of the browser session. + * @return The registered session authcode + */ + public String effectLogin ( + OOOUser user, int expires, HttpServletRequest req, HttpServletResponse rsp) + { + String authcode = _repository.registerSession(user, Math.max(expires, 1)); + Cookie acookie = new Cookie(_userAuthCookie, authcode); + // strip the hostname from the server and use that as the domain unless configured not to + if (!"false".equalsIgnoreCase(_config.getProperty("auth_cookie.strip_hostname"))) { + CookieUtil.widenDomain(req, acookie); + } + acookie.setPath("/"); + acookie.setMaxAge((expires > 0) ? (expires*24*60*60) : -1); + if (USERMGR_DEBUG) { + log.info("Setting cookie " + acookie + "."); + } + rsp.addCookie(acookie); + return authcode; + } + + /** + * Logs the user out. + */ + public void logout (HttpServletRequest req, HttpServletResponse rsp) + { + // nothing to do if they don't already have an auth cookie + String authcode = CookieUtil.getCookieValue(req, _userAuthCookie); + if (authcode == null) { + return; + } + + // set them up the bomb + Cookie c = new Cookie(_userAuthCookie, "x"); + c.setPath("/"); + c.setMaxAge(0); + CookieUtil.widenDomain(req, c); + if (USERMGR_DEBUG) { + log.info("Clearing cookie " + c + "."); + } + rsp.addCookie(c); + + // we need an unwidened one to ensure that old-style cookies are wiped as well + c = new Cookie(_userAuthCookie, "x"); + c.setPath("/"); + c.setMaxAge(0); + rsp.addCookie(c); + } + + /** + * Validates that the supplied session key is still valid and if so, refreshes it for the + * specified number of days. + * + * @return true if the session was located and refreshed, false otherwise. + */ + public boolean refreshSession (String sessionKey, int expireDays) + { + return _repository.refreshSession(sessionKey, expireDays); + } + + /** + * Called by the user manager to create the user repository. Derived classes can override this + * and create a specialized repository if they so desire. + */ + protected DepotUserRepository createRepository (PersistenceContext pctx) + throws DatabaseException + { + return new DepotUserRepository(pctx); + } + + /** Our user manager configuration. */ + protected Properties _config; + + /** The user repository. */ + protected DepotUserRepository _repository; + + /** The interval for user session pruning. */ + protected Interval _pruner; + + /** The URL for the user login page. */ + protected String _loginURL; + + /** The name of our user authentication cookie. */ + protected String _userAuthCookie = USERAUTH_COOKIE; + + /** Maintains a mapping of affiliate tags. */ + protected Map _tagMap = Maps.newHashMap(); + + /** The URL to which we redirect users whose access is denied. */ + protected String _accessDeniedURL; + + /** The user authentication cookie name. */ + protected static final String USERAUTH_COOKIE = "id_"; + + /** Prune the session table every hour. */ + protected static final long SESSION_PRUNE_INTERVAL = 60L * 60L * 1000L; + + /** Indicates how long (in days) that a "persisting" session token should last. */ + protected static final int PERSIST_EXPIRE_DAYS = 30; + + /** Indicates how long (in days) that a "non-persisting" session token should last. */ + protected static final int NON_PERSIST_EXPIRE_DAYS = 1; + + /** Change this to true and recompile to debug cookie handling. */ + protected static final boolean USERMGR_DEBUG = false; +} diff --git a/src/main/java/com/threerings/user/depot/DepotUserRepository.java b/src/main/java/com/threerings/user/depot/DepotUserRepository.java new file mode 100644 index 0000000..6ff47e0 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/DepotUserRepository.java @@ -0,0 +1,1265 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import static com.threerings.user.Log.log; + +import java.sql.Date; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.DuplicateKeyException; +import com.samskivert.depot.Funcs; +import com.samskivert.depot.Ops; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.SchemaMigration; +import com.samskivert.depot.StringFuncs; +import com.samskivert.depot.annotation.Computed; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.clause.FieldOverride; +import com.samskivert.depot.clause.FromOverride; +import com.samskivert.depot.clause.GroupBy; +import com.samskivert.depot.clause.Join; +import com.samskivert.depot.clause.Limit; +import com.samskivert.depot.clause.OrderBy; +import com.samskivert.depot.clause.QueryClause; +import com.samskivert.depot.clause.Where; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.util.Builder3; +import com.samskivert.io.PersistenceException; +import com.samskivert.servlet.user.Password; +import com.samskivert.servlet.user.UserExistsException; +import com.samskivert.servlet.user.UserUtil; +import com.samskivert.servlet.user.Username; +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.Calendars; +import com.samskivert.util.HashIntMap; +import com.samskivert.util.IntIntMap; +import com.samskivert.util.StringUtil; +import com.samskivert.util.Tuple; +import com.threerings.user.DetailedUser; +import com.threerings.user.OOOAuxData; +import com.threerings.user.OOOBillAuxData; +import com.threerings.user.OOOUser; +import com.threerings.user.OOOUserCard; +import com.threerings.user.ValidateRecord; + +/** + * Depot implementation of the OOO user repository. + */ +@Singleton +public class DepotUserRepository extends DepotRepository +{ + /** A user's access level. */ + public static enum Access + { + /** Access granted, user is not banned nor coming from a tainted machine. */ + ACCESS_GRANTED, + + /** User is trying to create a new account from a tainted machine. */ + NEW_ACCOUNT_TAINTED, + + /** The user is banned from playing. */ + ACCOUNT_BANNED, + + /** The user can not create another free account on this machine. */ + NO_NEW_FREE_ACCOUNT, + + /** The user has bounced a check or reversed a payment. */ + DEADBEAT; + } + + @Computed @Entity + public static class CountRecord extends PersistentRecord + { + /** The computed count. */ + @Computed(fieldDefinition="count(*)") + public int count; + } + + @Inject public DepotUserRepository (PersistenceContext ctx) + { + super(ctx); + + // TEMP: 2009-06-02 + _ctx.registerMigration( + OOOUserRecord.class, new SchemaMigration.Retype(3, OOOUserRecord.PASSWORD)); + } + + /** + * Looks up a user by userid. + * + * @return the user with the specified user id or null if no user with that id exists. + */ + public OOOUser loadUser (int userId) + { + return toUser(load(OOOUserRecord.getKey(userId))); + } + + /** + * Looks up a user by username. + * + * @return the user with the specified username or null if no user with that username exists. + */ + public OOOUser loadUser (String username) + { + return loadUser(username, false); + } + + /** + * Loads up all users in the supplied set of user ids. + * + * @deprecated use #loadUsers. + */ + @Deprecated + public HashIntMap loadUsersFromId (Set userIds) + { + HashIntMap userMap = new HashIntMap(); + for (OOOUserRecord userRec : loadAll(OOOUserRecord.class, userIds)) { + userMap.put(userRec.userId, toUser(userRec)); + } + return userMap; + } + + /** + * Loads up all users in the supplied set of user ids. + */ + public Map loadUsers (Set userIds) + { + // TODO: remove loadUsersFromId, switch to just a HashMap + return loadUsersFromId(userIds); + } + + /** + * Loads up all users with the specified email. + */ + public Iterable lookupUsersByEmail (String email) + { + List users = Lists.newArrayList(); + for (OOOUserRecord record : findAll(OOOUserRecord.class, + new Where(OOOUserRecord.EMAIL, email))) { + users.add(toUser(record)); + } + return users; + } + + /** + * Looks up a user by username and optionally loads their machine identifier information. + * + * @return the user with the specified user id or null if no user with that id exists. + */ + public OOOUser loadUser (String username, boolean loadIdents) + { + return resolveIdents(toUser(load(OOOUserRecord.class, + new Where(StringFuncs.lower(OOOUserRecord.USERNAME).eq(username.toLowerCase())))), + loadIdents); + } + + /** + * Looks up a user by email address. + */ + public OOOUser loadUserByEmail (String email, boolean loadIdents) + { + return resolveIdents(toUser(load(OOOUserRecord.class, + new Where(StringFuncs.lower(OOOUserRecord.EMAIL).eq(email.toLowerCase())))), + loadIdents); + } + + /** + * Looks up a user by their session identifier. + * + * @return the user associated with the specified session or null of no session exists with the + * supplied identifier. + */ + public OOOUser loadUserBySession (String authcode) + { + return loadUserBySession(authcode, false); + } + + /** + * Looks up a user by their session identifier. + * + * @return the user associated with the specified session or null of no session exists with the + * supplied identifier. + */ + public OOOUser loadUserBySession (String authcode, boolean loadIdents) + { + SessionRecord sess = load(SessionRecord.getKey(authcode)); + // Check against the beginning of the day rather than right now because our database stores + // only the expire date, but the user may have a valid cookie that doesn't expire until + // later today. + long expireTime = Calendars.now().zeroTime().toTime(); + return (sess == null || sess.expires.getTime() < expireTime) ? null : + resolveIdents(toUser(load(OOOUserRecord.class, + OOOUserRecord.getKey(sess.userId))), loadIdents); + } + + /** + * Looks up the session identifer for the given user. + */ + public String loadSessionAuthcode (int userId) + { + SessionRecord sess = from(SessionRecord.class).where(SessionRecord.USER_ID, userId).load(); + return sess == null ? null : sess.authcode; + } + + /** + * Creates a new session for the specified user and returns the randomly generated session + * identifier for that session. If a session entry already exists for the specified user it + * will be reused. + * + * @param expireDays the number of days in which the session token should expire. + */ + public String registerSession (OOOUser user, int expireDays) + { + String authcode = loadSessionAuthcode(user.userId); + + // if we found one, update its expires time and reuse it + if (authcode != null) { + // figure out when to expire the session + Date expires = Calendars.now().addDays(expireDays).toSQLDate(); + updatePartial(SessionRecord.getKey(authcode), SessionRecord.EXPIRES, expires); + return authcode; + + } else { + // otherwise create a new one and insert it into the table + authcode = UserUtil.genAuthCode(user); + setSession(user.userId, authcode, expireDays); + return authcode; + } + } + + /** + * Creates a new session record for the specified user with the specified session identifier. + * + * @param expireDays the number of days in which the session token should expire + */ + public void setSession (int userId, String authcode, int expireDays) + { + SessionRecord sess = new SessionRecord(); + sess.authcode = authcode; + sess.userId = userId; + sess.expires = Calendars.now().addDays(expireDays).toSQLDate(); + insert(sess); + } + + /** + * Validates that the supplied session key is still valid and if so, refreshes it for the + * specified number of days. + * + * @return true if the session was located and refreshed, false if it no longer exists. + */ + public boolean refreshSession (String authcode, int expireDays) + { + Date expires = Calendars.now().addDays(expireDays).toSQLDate(); + // attempt to update an existing session record, returning true if we found and updated it + return updatePartial(SessionRecord.getKey(authcode), SessionRecord.EXPIRES, expires) == 1; + } + + /** + * Prunes any expired sessions from the sessions table. + */ + public void pruneSessions () + { + Date now = new Date(System.currentTimeMillis()); + deleteAll(SessionRecord.class, new Where(SessionRecord.EXPIRES.lessEq(now))); + } + + /** + * Returns an array of usernames registered with the specified email address or the empty array + * if none are registered with said address. + */ + public String[] getUsernames (String email) + { + List usernames = Lists.newArrayList(); + Where where = new Where(OOOUserRecord.EMAIL, email); + for (OOOUserRecord record : findAll(OOOUserRecord.class, where)) { + usernames.add(record.username); + } + return usernames.toArray(new String[usernames.size()]); + } + + /** + * Returns an array of usernames from the supplied collection that have the specified token set. + */ + public List getTokenUsernames (Collection usernames, byte token) + { + // We're doing a manual token check after loading the users, however ideally having the + // depot support for a regexp comparison on a hex converted tokens field would be faster + List retnames = Lists.newArrayList(); + if (usernames.size() > 0) { + Where where = new Where(OOOUserRecord.USERNAME.in(usernames)); + for (OOOUserRecord record : findAll(OOOUserRecord.class, where)) { + if (record.holdsToken(token)) { + retnames.add(record.username); + } + } + } + return retnames; + } + + /** + * Loads up the machine ident information for the supplied user. + */ + public String[] loadMachineIdents (int userId) + { + List idents = Lists.newArrayList(); + Where where = new Where(UserIdentRecord.USER_ID, userId); + for (UserIdentRecord record : findAll(UserIdentRecord.class, where)) { + if (!StringUtil.isBlank(record.machIdent)) { +// log.info("Adding machine ident", "userId", userId, "machIdent", record.machIdent); + idents.add(record.machIdent); + } + } + String[] machIdents = idents.toArray(new String[idents.size()]); + Arrays.sort(machIdents); // sort the idents in java to ensure correct collation + return machIdents; + } + + /** + * Loads up the machine ident information for the supplied user. + */ + public void loadMachineIdents (OOOUser user) + { + user.machIdents = loadMachineIdents(user.userId); + } + + /** + * Returns a list of all users that have ever reported the specified machine identifier. + * TODO: is this used anywhere outside of underwire? If not, it can be removed. + */ + public List> getUsersOfMachIdent (String machIdent) + { + List> users = Lists.newArrayList(); + Join join = new Join(UserIdentRecord.class, Ops.and( + OOOUserRecord.USER_ID.eq(UserIdentRecord.USER_ID), + UserIdentRecord.MACH_IDENT.eq(machIdent))); + for (OOOUserRecord record : findAll(OOOUserRecord.class, join)) { + users.add(new Tuple(record.userId, record.username)); + } + return users; + } + + /** + * Returns a list of all usernames and their flags that have ever reported the specified + * machine identifier. + */ + public List getUsersOfMachIdentCards (String machIdent) + { + return from(OOOUserRecord.class) + .where(UserIdentRecord.MACH_IDENT, machIdent) + .join(OOOUserRecord.USER_ID, UserIdentRecord.USER_ID) + .select(BUILD_OOO_USER_CARD, + OOOUserRecord.USER_ID, OOOUserRecord.USERNAME, OOOUserRecord.FLAGS); + } + + /** + * Returns a list of all users that have ever reported the specified machine identifier. + */ + public List> getUsersOfMachIdents (String[] idents) + { + List> users = Lists.newArrayList(); + Join join = new Join(UserIdentRecord.class, Ops.and( + OOOUserRecord.USER_ID.eq(UserIdentRecord.USER_ID), + UserIdentRecord.MACH_IDENT.in(Arrays.asList(idents)))); + for (OOOUserRecord record : findAll(OOOUserRecord.class, join)) { + users.add(new Tuple(record.userId, record.username)); + } + return users; + } + + /** + * Add the userId -> machIdent mapping to the database. + */ + public void addUserIdent (int userId, String machIdent) + { + // don't add blank or null idents + if (!StringUtil.isBlank(machIdent)) { + try { + insert(new UserIdentRecord(userId, machIdent)); + } catch (DuplicateKeyException dke) { + // ignore, since the cache may have lied about this record not being present + } + } + } + + /** + * Returns the number of times this machIdent appears. + */ + public int getMachineIdentCount (String machIdent) + { + return load(CountRecord.class, new FromOverride(UserIdentRecord.class), + new Where(UserIdentRecord.MACH_IDENT, machIdent)).count; + } + + /** + * Checks to see if the specified machine identifier is tainted. + */ + public boolean isTaintedIdent (String machIdent) + { + // blank or null idents can't be tainted + if (StringUtil.isBlank(machIdent)) { + return false; + } + return load(TaintedIdentRecord.getKey(machIdent)) != null; + } + + /** + * Returns the subset of the supplied machine idents that are tainted. + */ + public Collection filterTaintedIdents (String[] idents) + { + if (idents == null || idents.length == 0) { + return Collections.emptyList(); + } + + return from(TaintedIdentRecord.class) + .where(TaintedIdentRecord.MACH_IDENT.in(idents)) + .select(TaintedIdentRecord.MACH_IDENT); + } + + /** + * Store to the database that the passed in machIdent has been tainted by a banned player. + */ + public void addTaintedIdent (String machIdent) + { + // don't taint blank or null idents + if (!StringUtil.isBlank(machIdent)) { + try { + insert(new TaintedIdentRecord(machIdent)); + } catch (DuplicateKeyException dke) { + // that's fine + } + } + } + + /** + * Remove a machine ident from the tainted table. + */ + public void removeTaintedIdent (String machIdent) + { + delete(TaintedIdentRecord.getKey(machIdent)); + } + + /** + * Checks to see if the specified machine identifier is banned. + */ + public boolean isBannedIdent (String machIdent, int siteId) + { + // blank or null idents can't be tainted + if (StringUtil.isBlank(machIdent)) { + return false; + } + return load(BannedIdentRecord.getKey(machIdent, siteId)) != null; + } + + /** + * Returns the subset of the supplied machine idents that are banned. + */ + public Collection filterBannedIdents (String[] idents, int siteId) + { + if (idents == null || idents.length == 0) { + return Collections.emptyList(); + } + return from(BannedIdentRecord.class) + .where(BannedIdentRecord.SITE_ID.eq(siteId), BannedIdentRecord.MACH_IDENT.in(idents)) + .select(BannedIdentRecord.MACH_IDENT); + } + + /** + * Store to the database that the passed in machIdent has been banned on the site. + */ + public void addBannedIdent (String machIdent, int siteId) + { + insert(new BannedIdentRecord(machIdent, siteId)); + } + + /** + * Remove a machine ident from the banned table. + */ + public void removeBannedIdent (String machIdent, int siteId) + { + delete(BannedIdentRecord.getKey(machIdent, siteId)); + } + + /** + * Creates a new user record in the repository with no auxiliary data. + */ + public int createUser (Username username, String password, String email, int siteId) + throws UserExistsException + { + return createUser(username, Password.makeFromCrypto(password), email, siteId, 0); + } + + /** + * Creates a new user record in the repository with no auxiliary data. + */ + public int createUser ( + Username username, Password password, String email, int siteId, int tagId) + throws UserExistsException + { + return createUser(username, password, email, siteId, tagId, null, (byte)-1, null); + } + + /** + * Creates a new user record in the repository. + */ + public int createUser (Username username, Password password, String email, int siteId, + int tagId, int birthyear, byte gender, String missive) + throws UserExistsException + { + // convert birth year to a fake birthday (Jan 1 of that year) + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.YEAR, birthyear); + cal.set(Calendar.DAY_OF_MONTH, 1); + cal.set(Calendar.MONTH, 0); + return createUser(username, password, email, siteId, tagId, + new Date(cal.getTimeInMillis()), gender, missive); + } + + /** + * Creates a new user record in the repository. + */ + public int createUser (Username username, Password password, String email, int siteId, + int tagId, Date birthday, byte gender, String missive) + throws UserExistsException + { + OOOUserRecord user = new OOOUserRecord(); + + // fill in the base user information + user.username = username.getUsername(); + user.password = password.getEncrypted(); + user.realname = ""; + user.email = email; + user.created = new Date(System.currentTimeMillis()); + user.siteId = siteId; + + // fill in the ooo-specific user information + user.tokens = new byte[0]; + user.spots = ""; + user.affiliateTagId = tagId; + + try { + insert(user); + } catch (DuplicateKeyException e) { + throw new UserExistsException("error.user_exists"); + } + + // store the auxiliary data (if provided) + if (birthday != null || gender >= 0 || missive != null) { + OOOAuxDataRecord record = new OOOAuxDataRecord(); + record.userId = user.userId; + record.birthday = birthday; + record.gender = gender; + record.missive = StringUtil.truncate((missive == null ? "" : missive), 255); + insert(record); + } + + HistoricalUserRecord hrec = new HistoricalUserRecord(); + hrec.userId = user.userId; + hrec.username = user.username; + hrec.created = user.created; + hrec.siteId = siteId; + insert(hrec); + + return user.userId; + } + + /** + * Changes a user's username. + * + * @return true if the old username existed and was changed to the new name, false if the old + * username did not exist. + * + * @exception UserExistsException thrown if the new name is already in use. + */ + public boolean changeUsername (int userId, String username) + throws UserExistsException + { + try { + return 0 != updatePartial(OOOUserRecord.getKey(userId), + OOOUserRecord.USERNAME, username); + } catch (DuplicateKeyException pe) { + throw new UserExistsException("error.user_exists"); + } + } + + /** + * Updates the specified user's email address. + */ + public void changeEmail (int userId, String email) + { + updatePartial(OOOUserRecord.getKey(userId), OOOUserRecord.EMAIL, email); + } + + /** + * Updates the specified user's password (should already be encrypted). + */ + public void changePassword (int userId, String password) + { + updatePartial(OOOUserRecord.getKey(userId), OOOUserRecord.PASSWORD, password); + } + + /** + * Updates a user that was previously fetched from the repository. Only fields that have been + * modified since it was loaded will be written to the database and those fields will + * subsequently be marked clean once again. + * + * @return true if the record was updated, false if the update was skipped because no fields in + * the user record were modified. + */ + public boolean updateUser (OOOUser user) + { + OOOUserRecord.DepotOOOUser duser = (OOOUserRecord.DepotOOOUser)user; + if (duser.mods == null) { + return false; + } + update(OOOUserRecord.fromUser(user), duser.mods.toArray(new ColumnExp[duser.mods.size()])); + duser.mods = null; + return true; + } + + /** + * 'Delete' the users account such that they can no longer access it, however we do not delete + * the record from the db. The name is changed such that the original name has XX=FOO if the + * name were FOO originally. If we have to lop off any of the name to get our prefix to fit we + * use a minus sign instead of a equals side. The password field is set to be the empty string + * so that no one can log in (since nothing hashes to the empty string. We also make sure + * their email address no longer works, so in case we don't ignore 'deleted' users when we do + * the sql to get emailaddresses for the mass mailings we still won't spam delete folk. We + * leave the emailaddress intact exect for the @ sign which gets turned to a #, so that we can + * see what their email was incase it was an accidently deletion and we have to verify through + * email. + */ + public void deleteUser (OOOUser user) + { + if (user.isDeleted()) { + return; + } + + OOOUserRecord.DepotOOOUser duser = (OOOUserRecord.DepotOOOUser)user; + duser.setModified("username"); + duser.setModified("password"); + duser.setModified("email"); + + OOOUserRecord record = OOOUserRecord.fromUser(duser); + + // 'disable' their email address + String newEmail = duser.email.replace('@', '#'); + + String oldName = user.username; + for (int ii = 0; ii < 100; ii++) { + try { + String newUsername = StringUtil.truncate(ii + "=" + oldName, 24); + updatePartial(OOOUserRecord.getKey(record.userId), + OOOUserRecord.USERNAME, newUsername, + OOOUserRecord.EMAIL, newEmail, + OOOUserRecord.PASSWORD, ""); + } catch (DuplicateKeyException e) { + // try again + continue; + } + return; + } + } + + /** + * Mark this user's account as banned, update the tainted machine idents table as needed. + * + * @return true if the user exists and was banned, false if not. + */ + public boolean ban (int site, String username) + { + OOOUser user = loadUser(username, false); + if (user == null) { + return false; + } + + if (!user.setBanned(site, true)) { + return false; + } + updateUser(user); + String[] idents = loadMachineIdents(user.userId); + Collection tainted = filterTaintedIdents(idents); + for (String id : idents) { + if (!tainted.contains(id)) { + addTaintedIdent(id); + } + } + return true; + } + + /** + * Remove the ban from the users account, optionally untainting his machine idents. + * + * @return true if the user exists and was unbanned, false if not. + */ + public boolean unban (int site, String username, boolean untaint) + { + // Not currently tainting every system this user has ever touched or will touch in the + // future + OOOUser user = loadUser(username, untaint); + if (user == null) { + return false; + } + + if (!user.setBanned(site, false)) { + return false; + } + updateUser(user); + + if (untaint) { + for (String machIdent : user.machIdents) { + removeTaintedIdent(machIdent); + } + } + return true; + } + + /** + * Checks whether or not the user in question should be allowed access. + * + * @param site the site for which we are validating the user. + * @param newPlayer true if the user is attempting to create a new game account. + */ + public Access validateUser (int site, OOOUser user, String machIdent, boolean newPlayer) + { + // if this user's idents were not loaded, complain + if (user.machIdents == OOOUser.IDENTS_NOT_LOADED) { + log.warning("Requested to validate user with unloaded idents", + "who", user.username, new Exception()); + // err on the side of not screwing our customers + return Access.ACCESS_GRANTED; + } + + // if we have never seen them before... + if (user.machIdents == null) { + // add their ident to the userobject and db + user.machIdents = new String[] { machIdent }; + addUserIdent(user.userId, machIdent); + + } else if (Arrays.binarySearch(user.machIdents, machIdent) < 0) { + // add the machIdent to the users list of associated idents + user.machIdents = ArrayUtil.append(user.machIdents, machIdent); + // and slap it in the db + addUserIdent(user.userId, machIdent); + } + + // if this is a banned user, mark that ident + if (user.isBanned(site)) { + addTaintedIdent(machIdent); + return Access.ACCOUNT_BANNED; + } + + // if this is a banned machIdent just return banned status + if (isBannedIdent(machIdent, site)) { + return Access.ACCOUNT_BANNED; + } + + // don't let those bastards grief us. + if (newPlayer && (isTaintedIdent(machIdent)) ) { + return Access.NEW_ACCOUNT_TAINTED; + } + + // if the user has bounced a check or reversed payment, let them know + if (user.isDeadbeat(site)) { + return Access.DEADBEAT; + } + + // if they have 0 sessions and they're not a subscriber, then make sure there aren't too + // many other free accounts already created with this machine ident + if (!user.isSubscriber() && !user.hasBoughtCoins() && newPlayer && + (playedRecentFreeAccounts(machIdent, RECENT_ACCOUNT_CUTOFF) > + MAX_FREE_ACCOUNTS_PER_MACHINE)) { + return Access.NO_NEW_FREE_ACCOUNT; + } + + // you're all clear kid... + return Access.ACCESS_GRANTED; + } + + /** + * Checks whether or not the machine in question should be allowed access. + * + * @param newPlayer true if the user is attempting to create a new game + * account. + * @param site the site we're trying to validate this machine on. + */ + public Access validateIdent (int site, String machIdent, boolean newPlayer) + { + // if this is a banned machIdent just return banned status + if (isBannedIdent(machIdent, site)) { + return Access.ACCOUNT_BANNED; + } + + // don't let those bastards grief us. + if (newPlayer && isTaintedIdent(machIdent)) { + return Access.NEW_ACCOUNT_TAINTED; + } + + // make sure there aren't too many other free accounts already created with this ident + if (newPlayer && (playedRecentFreeAccounts(machIdent, RECENT_ACCOUNT_CUTOFF) > + MAX_FREE_ACCOUNTS_PER_MACHINE)) { + return Access.NO_NEW_FREE_ACCOUNT; + } + + // you're all clear kid... + return Access.ACCESS_GRANTED; + } + + /** + * Loads all subaffiliate mappings. + */ + public List loadAffiliateTags () + { + return findAll(AffiliateTagRecord.class); + } + + /** + * Adds a new affiliate tag mapping and returns the assigned identifier. + */ + public int registerAffiliateTag (String tag) + { + AffiliateTagRecord record = + load(AffiliateTagRecord.class, new Where(AffiliateTagRecord.TAG, tag)); + if (record == null) { + record = new AffiliateTagRecord(); + record.tag = tag; + insert(record); + } + return record.tagId; + } + + /** + * Returns the afilliate tag for the id, or null if it doesn't exist. + */ + public String loadAffiliateTag (int tagId) + { + AffiliateTagRecord record = load(AffiliateTagRecord.getKey(tagId)); + return record == null ? null : record.tag; + } + + /** + * Returns a list of detail records for the users registered in our database, starting with the + * specified record number and containing at most count elements. + * + * @param filter if true, unvalidated users and users that are already testers or the like are + * filtered out. + */ + public List getDetailRecords (int start, int count, boolean filter) + { + List users = Lists.newArrayList(); + List clauses = Lists.newArrayList(); + clauses.add(new FromOverride(OOOUserRecord.class)); + clauses.add(new Join( + OOOUserRecord.USER_ID, OOOAuxDataRecord.USER_ID).setType(Join.Type.LEFT_OUTER)); + if (filter) { + clauses.add(new Where(Ops.and(OOOUserRecord.FLAGS.notEq(0), + Funcs.arrayLength(OOOUserRecord.TOKENS).eq(0)))); + } + clauses.add(OrderBy.descending(OOOUserRecord.USER_ID)); + clauses.add(new Limit(start, count)); + for (DetailedUserRecord record : findAll(DetailedUserRecord.class, clauses)) { + users.add(record.toDetailedUser()); + } + return users; + } + + /** + * Returns a list of all detail records that match the specified search string in their + * username or email address. + */ + public List searchDetailRecords (String term) + { + String likeTerm = "%" + term + "%"; + List users = Lists.newArrayList(); + List clauses = Lists.newArrayList(); + clauses.add(new FromOverride(OOOUserRecord.class)); + clauses.add(new Join( + OOOUserRecord.USER_ID, OOOAuxDataRecord.USER_ID).setType(Join.Type.LEFT_OUTER)); + clauses.add(new Where(Ops.or(OOOUserRecord.USERNAME.like(likeTerm), + OOOUserRecord.EMAIL.like(likeTerm)))); + clauses.add(OrderBy.descending(OOOUserRecord.USER_ID)); + for (DetailedUserRecord record : findAll(DetailedUserRecord.class, clauses)) { + users.add(record.toDetailedUser()); + } + return users; + } + + /** + * Creates a pending record for an account that has been created but not yet validated (which + * involves the user accessing a secret URL we send to them in an email message). If a record + * already exists for the supplied user, it will be reused and returned. + */ + public ValidateRecord createValidateRecord (int userId, boolean persist) + { + // reuse an existing record if we have one + ValidateRecord rec = getValidateRecord(userId); + if (rec != null) { + return rec; + } + + // otherwise create a new one and insert it into the database + rec = new ValidateRecord(); + rec.secret = Long.toString(Math.abs((new Random()).nextLong()), 16); + rec.userId = userId; + rec.persist = persist; + rec.inserted = new Date(System.currentTimeMillis()); + insert(ValidateDepotRecord.fromValidateRecord(rec)); + return rec; + } + + /** + * Fetches the validate record matching the specified secret and removes it from the pending + * validations table. + */ + public ValidateRecord getValidateRecord (final String secret) + { + ValidateDepotRecord record = load(ValidateDepotRecord.getKey(secret)); + if (record == null) { + return null; + } + delete(record); + return record.toValidateRecord(); + } + + /** + * Fetches the validate record for the specifed user. + */ + public ValidateRecord getValidateRecord (final int userId) + { + ValidateDepotRecord record = + load(ValidateDepotRecord.class, new Where(ValidateDepotRecord.USER_ID, userId)); + return record == null ? null : record.toValidateRecord(); + } + + /** + * Purges expired validation records from the table. + */ + public void purgeValidationRecords () + { + deleteAll(ValidateDepotRecord.class, new Where(ValidateDepotRecord.INSERTED.lessThan( + Calendars.now().zeroTime().addMonths(-1).toSQLDate()))); + } + + /** + * Returns the total number of registered users and the number of users that registered in the + * last 24 hours. We love the stats. + */ + public int[] getRegiStats () + { + int[] data = new int[2]; + data[0] = load(CountRecord.class, new FromOverride(HistoricalUserRecord.class)).count; + data[1] = load(CountRecord.class, new FromOverride(HistoricalUserRecord.class), + new Where(HistoricalUserRecord.CREATED, new Date(System.currentTimeMillis())) + ).count; + return data; + } + + /** + * Returns the count of registrations per day for the last limit days. The + * returned tuple array contains ({@link Date}, count) pairs. + */ + public List> getRecentRegCount (int limit) + { + List> list = Lists.newArrayList(); + for (RecentUserRecord recent : findAll(RecentUserRecord.class, + new FromOverride(HistoricalUserRecord.class), + new GroupBy(HistoricalUserRecord.CREATED), + OrderBy.descending(HistoricalUserRecord.CREATED), + new Limit(0, limit))) { + list.add(new Tuple(recent.created, recent.entries)); + } + return list; + } + + /** + * Returns a new Set that is a subset of the names in the provided collection, the new Set + * containing only usernames that have purchased coins. The original collection is not + * modified. + */ + public Set filterCoinBuyers (final Collection usernames) + { + Set filtered = Sets.newHashSet(); + List records = findAll(OOOUserRecord.class, + new FromOverride(OOOUserRecord.class, OOOBillAuxDataRecord.class), + new Where(Ops.and( + OOOUserRecord.USERNAME.in(usernames), + OOOBillAuxDataRecord.USER_ID.eq(OOOUserRecord.USER_ID), + Ops.not(OOOBillAuxDataRecord.FIRST_COIN_BUY.isNull())))); + for (OOOUserRecord record : records) { + filtered.add(record.username); + } + return filtered; + } + + /** + * Returns a new Set that is a subset of the userIds in the provided collection, the new Set + * containing only userIds that have purchased coins for the first time in the interval + * provided. The original collection is not modified. + */ + public Set filterNewCoinBuyers ( + final Collection userIds, final Date start, final Date end) + { + Set filtered = Sets.newHashSet(); + for (OOOBillAuxDataRecord record : findAll(OOOBillAuxDataRecord.class, + new Where(Ops.and( + OOOBillAuxDataRecord.USER_ID.in(userIds), + OOOBillAuxDataRecord.FIRST_COIN_BUY.greaterEq(start), + OOOBillAuxDataRecord.FIRST_COIN_BUY.lessEq(end))))) { + filtered.add(record.userId); + } + return filtered; + } + + /** + * Loads up the aux data record for the specified user. Returns null if none exists for that id. + */ + public OOOAuxData getAuxRecord (int userId) + { + OOOAuxDataRecord record = load(OOOAuxDataRecord.class, + new Where(OOOAuxDataRecord.USER_ID.eq(userId))); + return record == null ? null : record.toOOOAuxData(); + } + + /** + * Loads up the billing aux data record for the specified user. Returns a blank record (with + * userId filled in) if none exists for that id. + */ + public OOOBillAuxData getBillAuxData (int userId) + { + OOOBillAuxDataRecord bauxr = load(OOOBillAuxDataRecord.class, + new Where(OOOBillAuxDataRecord.USER_ID.eq(userId))); + if (bauxr == null) { + OOOBillAuxData baux = new OOOBillAuxData(); + baux.userId = userId; + return baux; + } + return bauxr.toOOOBillAuxData(); + } + + /** + * Updates the supplied record in the database, creating the record if necessary. + */ + public void updateBillAuxData (OOOBillAuxData record) + { + store(OOOBillAuxDataRecord.fromOOOBillAuxData(record)); + } + + /** + * Gets the number of users referred by affiliates who bought coins within the given date range. + */ + public IntIntMap getAffiliateCoinBuyerCount (Date start, Date end) + throws PersistenceException + { + return getAffiliateUserCount( + createAffiliateClause(start, end), + new Join(OOOUserRecord.class, + Ops.and(HistoricalUserRecord.USER_ID.eq(OOOUserRecord.USER_ID), + OOOUserRecord.FLAGS.bitAnd(OOOUser.HAS_BOUGHT_COINS_FLAG).notEq(0) + ) + ) + ); + } + + /** + * Gets the number of users referred by a particular affiliate who bought coins for each date + * within the given date range. + */ + public Map getAffiliateCoinBuyerCounts (Date start, Date end, int affiliateId) + throws PersistenceException + { + return getAffiliateCounts( + createAffiliateClause(start, end, affiliateId), + new Join(OOOUserRecord.class, + Ops.and(HistoricalUserRecord.USER_ID.eq(OOOUserRecord.USER_ID), + OOOUserRecord.FLAGS.bitAnd(OOOUser.HAS_BOUGHT_COINS_FLAG).notEq(0) + ) + ) + ); + } + + /** + * Gets the number of users created per affiliate in the given date range. + */ + public IntIntMap getAffiliateRegistrationCount (Date start, Date end) + throws PersistenceException + { + return getAffiliateUserCount(createAffiliateClause(start, end)); + } + + /** + * Gets the number of users created for a particular affiliate per day in the given date range. + */ + public Map getAffiliateRegistrationCounts (Date start, Date end, int affiliateId) + throws PersistenceException + { + return getAffiliateCounts(createAffiliateClause(start, end, affiliateId)); + } + + /** + * Creates a where clause for users created within the given date range. + */ + protected static Where createAffiliateClause (Date start, Date end) + throws PersistenceException + { + return new Where( + Ops.and( + HistoricalUserRecord.CREATED.greaterEq(start), + HistoricalUserRecord.CREATED.lessEq(end) + ) + ); + } + + /** + * Creates a where clause for users created within the given date range referred by the given + * affiliate id. + */ + public static Where createAffiliateClause (Date start, Date end, int affiliateId) + { + return new Where( + Ops.and( + HistoricalUserRecord.CREATED.greaterEq(start), + HistoricalUserRecord.CREATED.lessEq(end), + HistoricalUserRecord.SITE_ID.eq(affiliateId) + ) + ); + } + + /** + * Helper function for affiliate user count queries. + */ + protected IntIntMap getAffiliateUserCount (QueryClause ... clauses) + throws PersistenceException + { + List queryClauses = Lists.newArrayList(); + queryClauses.add(new FromOverride(HistoricalUserRecord.class)); + for (QueryClause additional : clauses) { + if (additional != null) { + queryClauses.add(additional); + } + } + queryClauses.add(new GroupBy(AffiliateUserCountRecord.SITE_ID)); + List userCounts = findAll(AffiliateUserCountRecord.class, + queryClauses); + + IntIntMap results = new IntIntMap(); + for (AffiliateUserCountRecord record : userCounts) { + results.put(record.siteId, record.count); + } + return results; + } + + /** + * Helper function for affiliate user count queries. + */ + protected Map getAffiliateCounts (QueryClause ... clauses) + throws PersistenceException + { + List queryClauses = Lists.newArrayList(); + queryClauses.add(new FromOverride(HistoricalUserRecord.class)); + for (QueryClause additional : clauses) { + queryClauses.add(additional); + } + queryClauses.add(new GroupBy(AffiliateCountRecord.CREATED)); + queryClauses.add(OrderBy.descending(AffiliateCountRecord.CREATED)); + List userCounts = findAll(AffiliateCountRecord.class, queryClauses); + Map results = new LinkedHashMap(); + for (AffiliateCountRecord record : userCounts) { + results.put(record.created, record.count); + } + return results; + } + + /** + * Returns the max userid currently in use. + */ + protected int getMaxUserId () + { + return load(MaxUserRecord.class, + new FromOverride(OOOUserRecord.class), + new FieldOverride(MaxUserRecord.USER_ID, Funcs.max(OOOUserRecord.USER_ID))).userId; + } + + /** + * Returns the number of free accounts that have been played at least + * once from this machine ident and were created vaguely recently. + * Returns the number of free accounts that have been played at least once from this machine + * ident and were created vaguely recently. + * + * @param daysInThePast is a negative number representing days to go back. + */ + protected int playedRecentFreeAccounts (String machIdent, int daysInThePast) + { + Date since = Calendars.now().addDays(daysInThePast).toSQLDate(); + return load(CountRecord.class, new FromOverride(OOOUserRecord.class, UserIdentRecord.class), + new Where(Ops.and( + OOOUserRecord.USER_ID.eq(UserIdentRecord.USER_ID), + Ops.not(Ops.like(OOOUserRecord.USERNAME, "%=%")), + UserIdentRecord.MACH_IDENT.eq(machIdent), + OOOUserRecord.FLAGS.bitAnd(OOOUser.HAS_BOUGHT_COINS_FLAG).eq(0), + OOOUserRecord.CREATED.greaterThan(since)))) + .count; + } + + /** + * Converts a possibly null OOOUserRecord to a OOOUser. + */ + protected OOOUser toUser (OOOUserRecord record) + { + return (record == null ? null : record.toUser()); + } + + /** + * Optionally resolves machine identifiers for the supplied user. + */ + protected OOOUser resolveIdents (OOOUser user, boolean loadIdents) + { + if (user != null && loadIdents) { + user.machIdents = loadMachineIdents(user.userId); + } + return user; + } + + @Override + protected void getManagedRecords (Set> classes) + { + classes.add(AffiliateTagRecord.class); + classes.add(BannedIdentRecord.class); + classes.add(HistoricalUserRecord.class); + classes.add(OOOAuxDataRecord.class); + classes.add(OOOUserRecord.class); + classes.add(SessionRecord.class); + classes.add(TaintedIdentRecord.class); + classes.add(UserIdentRecord.class); + classes.add(ValidateDepotRecord.class); + } + + protected static final Builder3 BUILD_OOO_USER_CARD = + new Builder3() { + public OOOUserCard build (Integer userId, String userName, Integer flags) { + return new OOOUserCard(userId, userName, flags); + } + }; + + /** The number of days in the past from now where we no longer + * consider an account as 'recent' */ + protected static final int RECENT_ACCOUNT_CUTOFF = -3*30; + + /** The number of free accounts that can be created per machine. */ + protected static final int MAX_FREE_ACCOUNTS_PER_MACHINE = 2; +} diff --git a/src/main/java/com/threerings/user/depot/DetailedUserRecord.java b/src/main/java/com/threerings/user/depot/DetailedUserRecord.java new file mode 100644 index 0000000..7630f3b --- /dev/null +++ b/src/main/java/com/threerings/user/depot/DetailedUserRecord.java @@ -0,0 +1,93 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Computed; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.DetailedUser; + +/** + * A computed persistent entity that loads detailed user information. + */ +@Computed +@Entity +public class DetailedUserRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = DetailedUserRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp USERNAME = colexp(_R, "username"); + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp EMAIL = colexp(_R, "email"); + public static final ColumnExp BIRTHDAY = colexp(_R, "birthday"); + public static final ColumnExp GENDER = colexp(_R, "gender"); + public static final ColumnExp MISSIVE = colexp(_R, "missive"); + // AUTO-GENERATED: FIELDS END + + /** The user's assigned userid. */ + @Id @Computed(shadowOf=OOOUserRecord.class) + public int userId; + + /** The user's chosen username. */ + @Computed(shadowOf=OOOUserRecord.class) + public String username; + + /** The date this record was created. */ + @Computed(shadowOf=OOOUserRecord.class) + public Date created; + + /** The user's email address. */ + @Computed(shadowOf=OOOUserRecord.class) + public String email; + + /** The user's birthday. */ + @Column(name="BIRTHDAY") @Computed(shadowOf=OOOAuxDataRecord.class) + public Date birthday; + + /** The user's gender. */ + @Column(name="GENDER") @Computed(shadowOf=OOOAuxDataRecord.class) + public byte gender; + + /** The user's personal missive to us. */ + @Column(name="MISSIVE") @Computed(shadowOf=OOOAuxDataRecord.class) + public String missive; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link DetailedUserRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END + + /** + * Creates a DetailedUser from the record. + */ + public DetailedUser toDetailedUser () + { + DetailedUser user = new DetailedUser(); + user.userId = userId; + user.username = username; + user.created = created; + user.email = email; + user.birthday = birthday; + user.gender = gender; + user.missive = missive; + return user; + } +} diff --git a/src/main/java/com/threerings/user/depot/ExternalAuthRecord.java b/src/main/java/com/threerings/user/depot/ExternalAuthRecord.java new file mode 100644 index 0000000..788964b --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ExternalAuthRecord.java @@ -0,0 +1,60 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.ExternalAuther; + +/** + * Maintains information on a mapping from an external authentication source to a OOOUser. + */ +public class ExternalAuthRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = ExternalAuthRecord.class; + public static final ColumnExp AUTHER = colexp(_R, "auther"); + public static final ColumnExp EXTERNAL_ID = colexp(_R, "externalId"); + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp SESSION_KEY = colexp(_R, "sessionKey"); + // AUTO-GENERATED: FIELDS END + + /** Increment this value if you modify the definition of this persistent object in a way that + * will result in a change to its SQL counterpart. */ + public static final int SCHEMA_VERSION = 1; + + /** The auther that maintains the external user id. */ + @Id public ExternalAuther auther; + + /** The external user identifier. */ + @Id public String externalId; + + /** The id of the OOOUser account associated with the specified external account. */ + @Index(name="ixUserId") + public int userId; + + /** The most recent session key provided by the external site, for use in making API requests + * to said site based on our most recently active session. */ + @Column(nullable=true) + public String sessionKey; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ExternalAuthRecord} + * with the supplied key values. + */ + public static Key getKey (ExternalAuther auther, String externalId) + { + return newKey(_R, auther, externalId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(AUTHER, EXTERNAL_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/ExternalAuthRepository.java b/src/main/java/com/threerings/user/depot/ExternalAuthRepository.java new file mode 100644 index 0000000..1617564 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ExternalAuthRepository.java @@ -0,0 +1,128 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Maps; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.FluentExp; +import com.samskivert.util.Tuple; + +import com.threerings.user.ExternalAuther; + +/** + * Maps authentication information from external sources (like Facebook, OAuth providers, etc.) to + * our internal (ooouser) userId number. + */ +@Singleton +public class ExternalAuthRepository extends DepotRepository +{ + @Inject public ExternalAuthRepository (PersistenceContext ctx) + { + super(ctx); + } + + /** + * Returns the OOOUser id of the member with the supplied external credentials, or 0 if the + * supplied creds are not mapped to a OOOUser account. + */ + public int loadUserIdForExternal (ExternalAuther auther, String externalId) + { + Preconditions.checkNotNull(auther); + ExternalAuthRecord exrec = load(ExternalAuthRecord.getKey(auther, externalId)); + return (exrec == null) ? 0 : exrec.userId; + } + + /** + * Returns the external id of the given OOOUser with the given ExternalAuther. + */ + public String loadExternalIdForUser (ExternalAuther auther, int userId) + { + Preconditions.checkNotNull(auther); + ExternalAuthRecord exrec = from(ExternalAuthRecord.class).where( + ExternalAuthRecord.USER_ID.eq(userId).and(ExternalAuthRecord.AUTHER.eq(auther))).load(); + return (exrec == null) ? null : exrec.externalId; + } + + /** + * Returns the most recently saved session key for the supplied external account, or null if no + * mapping exists for said account or no key has yet been stored for it. + */ + public String loadExternalSessionKey (ExternalAuther auther, String externalId) + { + Preconditions.checkNotNull(auther); + ExternalAuthRecord exrec = load(ExternalAuthRecord.getKey(auther, externalId)); + return (exrec == null) ? null : exrec.sessionKey; + } + + /** + * Returns (externalId, sessionKey) for the supplied user, or null if no mapping exists for + * said user. The sessionKey may be null if no key has yet been stored. + */ + public Tuple loadExternalAuthInfo (ExternalAuther auther, int userId) + { + Preconditions.checkNotNull(auther); + ExternalAuthRecord exrec = from(ExternalAuthRecord.class).where( + ExternalAuthRecord.AUTHER.eq(auther), ExternalAuthRecord.USER_ID.eq(userId)).load(); + return (exrec == null) ? null : Tuple.newTuple(exrec.externalId, exrec.sessionKey); + } + + /** + * Loads a mapping of (exid -> ooouser id) for all members in the supplied list that are found + * in the database. + */ + public Map loadUserIds (ExternalAuther auther, Collection externalIds) + { + Preconditions.checkNotNull(auther); + Map ids = Maps.newHashMap(); + for (ExternalAuthRecord exrec : from(ExternalAuthRecord.class).where( + ExternalAuthRecord.AUTHER.eq(auther), + ExternalAuthRecord.EXTERNAL_ID.in(externalIds)).select()) { + ids.put(exrec.externalId, exrec.userId); + } + return ids; + } + + /** + * Creates a mapping for the specified user to the supplied external credentials. + */ + public void mapExternalAccount (int userId, ExternalAuther auther, String externalId, + String sessionKey) + { + Preconditions.checkNotNull(auther); + ExternalAuthRecord exrec = new ExternalAuthRecord(); + exrec.auther = auther; + exrec.externalId = externalId; + exrec.userId = userId; + exrec.sessionKey = sessionKey; + insert(exrec); + } + + /** + * Updates the session key on file for the specified user's mapping for the specified auther. + */ + public void updateExternalSession (ExternalAuther auther, String externalId, String sessionKey) + { + Preconditions.checkNotNull(auther); + updatePartial(ExternalAuthRecord.getKey(auther, externalId), + ExternalAuthRecord.SESSION_KEY, sessionKey); + } + + + @Override // from DepotRepository + protected void getManagedRecords (Set> classes) + { + classes.add(ExternalAuthRecord.class); + } +} + diff --git a/src/main/java/com/threerings/user/depot/HistoricalUserRecord.java b/src/main/java/com/threerings/user/depot/HistoricalUserRecord.java new file mode 100644 index 0000000..de10f92 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/HistoricalUserRecord.java @@ -0,0 +1,61 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Retains information about a historical user registration. + */ +@Entity(name="history") +public class HistoricalUserRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = HistoricalUserRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp USERNAME = colexp(_R, "username"); + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The user's assigned integer userid. */ + @Id + public int userId; + + /** The user's chosen username. */ + @Column + public String username; + + /** The date this record was created. */ + @Index + public Date created; + + /** The affiliate site with which this user is associated. */ + @Index + public int siteId; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link HistoricalUserRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/InvitationRecord.java b/src/main/java/com/threerings/user/depot/InvitationRecord.java new file mode 100644 index 0000000..35d366a --- /dev/null +++ b/src/main/java/com/threerings/user/depot/InvitationRecord.java @@ -0,0 +1,67 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * An invitation record. + */ +@Entity +public class InvitationRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = InvitationRecord.class; + public static final ColumnExp INVITATION = colexp(_R, "invitation"); + public static final ColumnExp EMAIL = colexp(_R, "email"); + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp INVITER_USER_ID = colexp(_R, "inviterUserId"); + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp SENT = colexp(_R, "sent"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 4; + + /** The invitation key. */ + @Id @Column(length=32) + public String invitation; + + /** The e-mail address this invitation was sent to. */ + public String email; + + /** The account activated with this invitation. */ + public int userId; + + /** The account used to generate the invitation. */ + @Index + public int inviterUserId; + + /** The date this invitation was created. */ + public Date created; + + /** The date the last e-mail was sent. */ + public Date sent; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link InvitationRecord} + * with the supplied key values. + */ + public static Key getKey (String invitation) + { + return newKey(_R, invitation); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(INVITATION); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/InvitationRepository.java b/src/main/java/com/threerings/user/depot/InvitationRepository.java new file mode 100644 index 0000000..8035eca --- /dev/null +++ b/src/main/java/com/threerings/user/depot/InvitationRepository.java @@ -0,0 +1,223 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import com.google.common.base.Function; +import com.google.common.collect.Lists; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.DuplicateKeyException; +import com.samskivert.depot.Key; +import com.samskivert.depot.KeySet; +import com.samskivert.depot.Ops; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.SchemaMigration; +import com.samskivert.depot.annotation.Computed; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.clause.FromOverride; +import com.samskivert.depot.clause.Limit; +import com.samskivert.depot.clause.OrderBy; +import com.samskivert.depot.clause.Where; + +/** + * The invitation repository. + */ +@Singleton +public class InvitationRepository extends DepotRepository +{ + @Computed @Entity + public static class CountRecord extends PersistentRecord + { + /** The computed count. */ + @Computed(fieldDefinition="count(*)") + public int count; + } + + @Inject public InvitationRepository (PersistenceContext ctx) + { + super(ctx); + + ctx.registerMigration(InvitationRecord.class, + new SchemaMigration.Retype(3, InvitationRecord.INVITER_USER_ID)); + ctx.registerMigration(InvitationRecord.class, + new SchemaMigration.Add(4, InvitationRecord.SENT, "'2000-01-01'")); + } + + /** + * Creates a new invitation record. + */ + public InvitationRecord createInvitationRecord (String email) + { + return createInvitationRecord(email, 0); + } + + /** + * Creates a new invitation record. + */ + public InvitationRecord createInvitationRecord (String email, String username) + { + OOOUserRecord user = load( + OOOUserRecord.class, new Where(OOOUserRecord.USERNAME.eq(username))); + return user == null ? null : createInvitationRecord(email, user.userId); + } + + /** + * Creates a new invitation record. + */ + public InvitationRecord createInvitationRecord (String email, int inviterId) + { + InvitationRecord rec = new InvitationRecord(); + rec.email = email; + rec.inviterUserId = inviterId; + rec.created = new Date(System.currentTimeMillis()); + rec.sent = rec.created; + for (int ii = 0; ii < 10; ii++) { + rec.invitation = Long.toString(Math.abs((new Random()).nextLong()), 16); + try { + insert(rec); + } catch (DuplicateKeyException e) { + continue; + } + return rec; + } + return null; + } + + /** + * Creates a new multiple invitation record. + */ + public MultipleInvitationRecord createMultipleInvitationRecord (String code, int max) + { + MultipleInvitationRecord rec = new MultipleInvitationRecord(); + rec.invitation = code; + rec.maxInvitations = max; + try { + insert(rec); + } catch (DuplicateKeyException e) { + return null; + } + return rec; + } + + /** + * Fetches an invitation record. + */ + public InvitationRecord getInvitation (String invitation) + { + return load(InvitationRecord.getKey(invitation)); + } + + /** + * Returns a list of invitations starting with the specified record number and containing at + * most count elements. + */ + public List getInvitationRecords (int start, int count) + { + return findAll(InvitationRecord.class, OrderBy.descending(InvitationRecord.CREATED), + new Limit(start, count)); + } + + /** + * Returns a list of unused invitations created before a certain date. + */ + public List getUnusedInvitationRecords (Date before) + { + return findAll(InvitationRecord.class, + new Where(Ops.and(InvitationRecord.USER_ID.eq(0), + InvitationRecord.CREATED.lessEq(before), + InvitationRecord.SENT.lessEq(before))), + OrderBy.ascending(InvitationRecord.EMAIL)); + } + + /** + * Returns true if the multiple invitation code is valid and has invitations remaining. + */ + public MultipleInvitationRecord getOpenMultipleInvitation (String code) + { + MultipleInvitationRecord rec = load(MultipleInvitationRecord.class, + new Where(MultipleInvitationRecord.INVITATION.eq(code))); + if (rec == null) { + return null; + } + return isMultipleInvitationOpen(rec) ? rec : null; + } + + /** + * Returns a list of multiple invtations records. + */ + public List getMultipleInvitations () + { + return findAll(MultipleInvitationRecord.class); + } + + /** + * Activates an invitation record. + */ + public void activateInvitation (InvitationRecord rec, int userId) + { + rec.userId = userId; + update(rec); + } + + /** + * Activates a multiple invitation. + * + * @return false if the user has already claimed an invitation + */ + public boolean activateMultipleInvitation (int invitationId, int userId) + { + UserInvitationRecord uir = new UserInvitationRecord(); + uir.invitationId = invitationId; + uir.userId = userId; + try { + insert(uir); + } catch (DuplicateKeyException e) { + return false; + } + return true; + } + + /** + * Updates the created date for a set of invitations. + */ + public void pokeInvitations (List invitations) + { + updatePartial(InvitationRecord.class, + new Where(InvitationRecord.INVITATION.in(invitations)), + KeySet.newKeySet(InvitationRecord.class, Lists.transform(invitations, + new Function>() { + public Key apply (String invitation) { + return InvitationRecord.getKey(invitation); + } + })), + InvitationRecord.SENT, new Date(System.currentTimeMillis())); + } + + /** + * Verifies a multiple invitation record is still open. + */ + protected boolean isMultipleInvitationOpen (MultipleInvitationRecord rec) + { + return (rec.maxInvitations > load(CountRecord.class, + new FromOverride(UserInvitationRecord.class), + new Where(UserInvitationRecord.INVITATION_ID.eq(rec.invitationId))).count); + } + + @Override + protected void getManagedRecords (Set> classes) + { + classes.add(InvitationRecord.class); + classes.add(MultipleInvitationRecord.class); + classes.add(UserInvitationRecord.class); + } +} diff --git a/src/main/java/com/threerings/user/depot/MaxUserRecord.java b/src/main/java/com/threerings/user/depot/MaxUserRecord.java new file mode 100644 index 0000000..6fbfcb6 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/MaxUserRecord.java @@ -0,0 +1,24 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Computed; + +/** + * Used to determine the max userId. + */ +@Computed +public class MaxUserRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = MaxUserRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + // AUTO-GENERATED: FIELDS END + + /** The user id. */ + @Computed + public int userId; +} diff --git a/src/main/java/com/threerings/user/depot/MultipleInvitationRecord.java b/src/main/java/com/threerings/user/depot/MultipleInvitationRecord.java new file mode 100644 index 0000000..4f187cd --- /dev/null +++ b/src/main/java/com/threerings/user/depot/MultipleInvitationRecord.java @@ -0,0 +1,54 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * A multiple invitation record. + */ +@Entity +public class MultipleInvitationRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = MultipleInvitationRecord.class; + public static final ColumnExp INVITATION_ID = colexp(_R, "invitationId"); + public static final ColumnExp INVITATION = colexp(_R, "invitation"); + public static final ColumnExp MAX_INVITATIONS = colexp(_R, "maxInvitations"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The invitation id. */ + @Id @GeneratedValue(strategy=GenerationType.AUTO) + public int invitationId; + + /** The invitation code. */ + @Column(length=32, unique=true) + public String invitation; + + /** The max number of accounts that can be created with this invitation. */ + public int maxInvitations; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link MultipleInvitationRecord} + * with the supplied key values. + */ + public static Key getKey (int invitationId) + { + return newKey(_R, invitationId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(INVITATION_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/OOOAuxDataRecord.java b/src/main/java/com/threerings/user/depot/OOOAuxDataRecord.java new file mode 100644 index 0000000..3b9ab20 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/OOOAuxDataRecord.java @@ -0,0 +1,88 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.OOOAuxData; + +/** + * Emulates {@link OOOAuxData} for the Depot. + */ +@Entity(name="AUXDATA") +public class OOOAuxDataRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = OOOAuxDataRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp BIRTHDAY = colexp(_R, "birthday"); + public static final ColumnExp GENDER = colexp(_R, "gender"); + public static final ColumnExp MISSIVE = colexp(_R, "missive"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The user's unique identifier. */ + @Id @Column(name="USER_ID") + public int userId; + + /** The user's birthday. */ + @Column(name="BIRTHDAY", defaultValue="'1900-01-01'") + public Date birthday; + + /** The user's gender. */ + @Column(name="GENDER", defaultValue="0") + public byte gender; + + /** The user's personal missive to us. */ + @Column(name="MISSIVE") + public String missive; + + /** + * Creates a OOOAuxDataRecord from a OOOAuxData. + */ + public static OOOAuxDataRecord fromOOOAuxData (OOOAuxData aux) + { + OOOAuxDataRecord record = new OOOAuxDataRecord(); + record.userId = aux.userId; + record.birthday = aux.birthday; + record.gender = aux.gender; + record.missive = aux.missive; + return record; + } + + /** + * Returns a OOOAuxData version of this record. + */ + public OOOAuxData toOOOAuxData () + { + OOOAuxData aux = new OOOAuxData(); + aux.userId = userId; + aux.birthday = birthday; + aux.gender = gender; + aux.missive = missive; + return aux; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link OOOAuxDataRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/OOOBillAuxDataRecord.java b/src/main/java/com/threerings/user/depot/OOOBillAuxDataRecord.java new file mode 100644 index 0000000..96381eb --- /dev/null +++ b/src/main/java/com/threerings/user/depot/OOOBillAuxDataRecord.java @@ -0,0 +1,81 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Timestamp; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.OOOBillAuxData; + +/** + * Emulates {@link OOOBillAuxData} for the Depot. + */ +@Entity(name="BILLAUXDATA") +public class OOOBillAuxDataRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = OOOBillAuxDataRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp FIRST_COIN_BUY = colexp(_R, "firstCoinBuy"); + public static final ColumnExp LATEST_COIN_BUY = colexp(_R, "latestCoinBuy"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 2; + + /** The user's unique identifier. */ + @Id @Column(name="USER_ID") + public int userId; + + /** The first time the user bought coins. */ + @Column(name="FIRST_COIN_BUY", nullable=true, defaultValue="NULL") + public Timestamp firstCoinBuy; + + /** The most recent time the user bought coins. */ + @Column(name="LATEST_COIN_BUY", nullable=true, defaultValue="NULL") + public Timestamp latestCoinBuy; + + /** + * Creates a OOOBillAuxDataRecord from OOOBillAuxData. + */ + public static OOOBillAuxDataRecord fromOOOBillAuxData (OOOBillAuxData aux) + { + OOOBillAuxDataRecord record = new OOOBillAuxDataRecord(); + record.userId = aux.userId; + record.firstCoinBuy = aux.firstCoinBuy; + record.latestCoinBuy = aux.latestCoinBuy; + return record; + } + + /** + * Returns a OOOBillAuxData version of this record. + */ + public OOOBillAuxData toOOOBillAuxData () + { + OOOBillAuxData aux = new OOOBillAuxData(); + aux.userId = userId; + aux.firstCoinBuy = firstCoinBuy; + aux.latestCoinBuy = latestCoinBuy; + return aux; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link OOOBillAuxDataRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/OOOUserRecord.java b/src/main/java/com/threerings/user/depot/OOOUserRecord.java new file mode 100644 index 0000000..036d504 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/OOOUserRecord.java @@ -0,0 +1,214 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; +import java.util.Set; + +import com.google.common.collect.Sets; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.StringFuncs; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.clause.OrderBy.Order; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.expression.SQLExpression; + +import com.samskivert.util.Tuple; + +import com.threerings.user.OOOUser; + +/** + * Emulates {@link OOOUser} for the Depot. + */ +@Entity(name="users", + indices={ @Index(name="ixLowerUsername", unique=true), @Index(name="ixLowerEmail") }) +public class OOOUserRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = OOOUserRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp USERNAME = colexp(_R, "username"); + public static final ColumnExp PASSWORD = colexp(_R, "password"); + public static final ColumnExp EMAIL = colexp(_R, "email"); + public static final ColumnExp REALNAME = colexp(_R, "realname"); + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp SITE_ID = colexp(_R, "siteId"); + public static final ColumnExp AFFILIATE_TAG_ID = colexp(_R, "affiliateTagId"); + public static final ColumnExp FLAGS = colexp(_R, "flags"); + public static final ColumnExp TOKENS = colexp(_R, "tokens"); + public static final ColumnExp YOHOHO = colexp(_R, "yohoho"); + public static final ColumnExp SPOTS = colexp(_R, "spots"); + public static final ColumnExp SHUN_LEFT = colexp(_R, "shunLeft"); + // AUTO-GENERATED: FIELDS END + + /** Fields that will be checked for modification and updated when calling + * {@link DepotUserRepository#updateUser}. */ + public static ColumnExp[] UPDATABLE_FIELDS = { + USERNAME, PASSWORD, EMAIL, REALNAME, AFFILIATE_TAG_ID, + FLAGS, TOKENS, YOHOHO, SPOTS, SHUN_LEFT + }; + + public static final int SCHEMA_VERSION = 4; + + /** The user's assigned integer userid. */ + @Id @GeneratedValue(strategy=GenerationType.AUTO) + public int userId; + + /** The user's chosen username. */ + @Column(length=128, unique=true) + public String username; + + /** The user's chosen password (encrypted). */ + @Column(length=128) + public String password; + + /** The user's email address. */ + @Column(length=128) @Index(name="ixEmail") + public String email; + + /** The user's real name (first, last and whatever else they opt to provide). */ + @Column(length=128) + public String realname; + + /** The date this record was created. */ + public Date created; + + /** The site identifier of the site through which the user created + * their account. (Their affiliation, if you will.) */ + public int siteId; + + /** The id of any opaque tag provided by the affiliate to tag this user for their purposes. */ + public int affiliateTagId; + + /** The flags detailing the user's various bits of status. (VALIDATED_FLAG, etc) */ + public int flags; + + /** The tokens detailing the user's site access permissions. (ADMIN, TESTER, etc) */ + public byte[] tokens; + + /** The user's account status for Puzzle Pirates. (TRIAL_STATE, SUBSCRIBER_STATE, etc) */ + public byte yohoho; + + /** The spots that have been given to the user by various crews. */ + @Column(length=128) + public String spots; + + /** The amount of time remaining on the users shun, in minutes. */ + public int shunLeft; + + /** + * Defines the index on {@link #username} converted to lower case. + */ + public static Tuple, Order> ixLowerUsername () + { + return new Tuple, Order>( + StringFuncs.lower(OOOUserRecord.USERNAME), Order.ASC); + } + + /** + * Defines the index on {@link #email} converted to lower case. + */ + public static Tuple, Order> ixLowerEmail () + { + return new Tuple, Order>(StringFuncs.lower(OOOUserRecord.EMAIL), Order.ASC); + } + + /** + * Creates a OOOUserRecord from a OOOUser. + */ + public static OOOUserRecord fromUser (OOOUser user) + { + OOOUserRecord record = new OOOUserRecord(); + record.userId = user.userId; + record.username = user.username; + record.created = user.created; + record.realname = user.realname; + record.password = user.password; + record.email = user.email; + record.siteId = user.siteId; + record.flags = user.flags; + record.tokens = user.tokens; + record.yohoho = user.yohoho; + record.spots = user.spots; + record.shunLeft = user.shunLeft; + record.affiliateTagId = user.affiliateTagId; + return record; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link OOOUserRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END + + /** + * Returns true if this user holds the specified token. + */ + public boolean holdsToken (byte token) + { + if (tokens == null) { + return false; + } + for (byte heldTok : tokens) { + if (heldTok == token) { + return true; + } + } + return false; + } + + /** + * Returns a OOOUser version of this record. + */ + public OOOUser toUser () + { + OOOUser user = new DepotOOOUser(); + user.userId = userId; + user.username = username; + user.created = created; + user.realname = realname; + user.password = password; + user.email = email; + user.siteId = siteId; + user.flags = flags; + user.tokens = tokens; + user.yohoho = yohoho; + user.spots = spots; + user.shunLeft = shunLeft; + user.affiliateTagId = affiliateTagId; + return user; + } + + /** + * A OOOUser with special dirty handling. + */ + protected class DepotOOOUser extends OOOUser + { + public Set> mods; + + @Override + protected void setModified (String field) + { + if (mods == null) { + mods = Sets.newHashSet(); + } + mods.add(colexp(_R, field)); + } + } +} diff --git a/src/main/java/com/threerings/user/depot/ProcessedActionRecord.java b/src/main/java/com/threerings/user/depot/ProcessedActionRecord.java new file mode 100644 index 0000000..2fc7ad7 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ProcessedActionRecord.java @@ -0,0 +1,48 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Actions that have been processed by a server. + */ +@Entity(name="PROCESSED_ACTIONS") +public class ProcessedActionRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = ProcessedActionRecord.class; + public static final ColumnExp ACTION_ID = colexp(_R, "actionId"); + public static final ColumnExp SERVER = colexp(_R, "server"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The action id. */ + @Id @Column(name="ACTION_ID") + public int actionId; + + /** The name of the server that has processed the action. */ + @Id @Column(name="SERVER") + public String server; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ProcessedActionRecord} + * with the supplied key values. + */ + public static Key getKey (int actionId, String server) + { + return newKey(_R, actionId, server); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(ACTION_ID, SERVER); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/RecentUserRecord.java b/src/main/java/com/threerings/user/depot/RecentUserRecord.java new file mode 100644 index 0000000..32127bd --- /dev/null +++ b/src/main/java/com/threerings/user/depot/RecentUserRecord.java @@ -0,0 +1,30 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Computed; + +/** + * Used to summarize recent registrants from the {@link HistoricalUserRecord} table. + */ +@Computed(shadowOf=HistoricalUserRecord.class) +public class RecentUserRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = RecentUserRecord.class; + public static final ColumnExp CREATED = colexp(_R, "created"); + public static final ColumnExp ENTRIES = colexp(_R, "entries"); + // AUTO-GENERATED: FIELDS END + + /** The created date. */ + public Date created; + + /** The number of users created on the date. */ + @Computed(fieldDefinition="count(*)") + public int entries; +} diff --git a/src/main/java/com/threerings/user/depot/ReferralRecord.java b/src/main/java/com/threerings/user/depot/ReferralRecord.java new file mode 100644 index 0000000..a2f5e05 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ReferralRecord.java @@ -0,0 +1,63 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Records information on a particular referral. + */ +@Entity(name="REFERRAL") +public class ReferralRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = ReferralRecord.class; + public static final ColumnExp REFERRAL_ID = colexp(_R, "referralId"); + public static final ColumnExp RECORDED = colexp(_R, "recorded"); + public static final ColumnExp REFERRER_ID = colexp(_R, "referrerId"); + public static final ColumnExp DATA = colexp(_R, "data"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 2; + + /** The refferal's assigned integer id. */ + @Id @GeneratedValue(strategy=GenerationType.AUTO) @Column(name="REFERRAL_ID") + public int referralId; + + /** The date the referral was made. */ + @Column(name="RECORDED", defaultValue="'1900-01-01'") @Index + public Date recorded; + + /** The user id of the referring user. */ + @Column(name="REFERRER_ID", defaultValue="0") @Index + public int referrerId; + + /** Data associated with this referral record. */ + @Column(name="DATA") + public String data; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ReferralRecord} + * with the supplied key values. + */ + public static Key getKey (int referralId) + { + return newKey(_R, referralId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(REFERRAL_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/ReferralRepository.java b/src/main/java/com/threerings/user/depot/ReferralRepository.java new file mode 100644 index 0000000..f104c65 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ReferralRepository.java @@ -0,0 +1,99 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; +import java.util.Set; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.clause.Where; +import com.samskivert.util.Calendars; + +/** + * Tracks the data needed for our personal referral system whereby one + * user sends a referral link (or email) to their friend and we track the + * original referring user if their referred friend actually registers + * within some reasonable time frame (like a couple of weeks). + */ +@Singleton +public class ReferralRepository extends DepotRepository +{ + @Inject public ReferralRepository (PersistenceContext ctx) + { + super(ctx); + } + + /** + * Records a new referral record in the system and returns the unique + * identifier for said record. + */ + public int recordReferral (int referrerId, String data) + { + final ReferralRecord record = new ReferralRecord(); + record.recorded = new Date(System.currentTimeMillis()); + record.referrerId = referrerId; + record.data = data; + insert(record); + + checkPurge(); + return record.referralId; + } + + /** + * Looks up a referral record with the specified id, returning null if + * no matching record could be found. + */ + public ReferralRecord lookupReferral (int referralId) + { + return load(ReferralRecord.getKey(referralId)); + } + + /** + * Looks up a referral record with the specified referring user id, + * returning null if no matching record could be found. + */ + public ReferralRecord lookupReferrer (int referrerId) + { + return load(ReferralRecord.class, new Where(ReferralRecord.REFERRER_ID, referrerId)); + } + + /** + * Used to periodically purge old referral records from the + * repository. + */ + protected void checkPurge () + { + long now = System.currentTimeMillis(); + if (now - _lastPurge > PURGE_INTERVAL) { + _lastPurge = now; + purgeStaleReferrals(); + } + } + + /** + * Purges stale referral records from the repository. + */ + protected void purgeStaleReferrals () + { + deleteAll(ReferralRecord.class, new Where(ReferralRecord.RECORDED.lessThan( + Calendars.now().zeroTime().addMonths(-1).toSQLDate()))); + } + + @Override // documentation inherited + protected void getManagedRecords (Set> classes) + { + classes.add(ReferralRecord.class); + } + + /** The last time we purged the repository of stale records. */ + protected long _lastPurge; + + /** We purge the repository every three hours of stale records. */ + protected static final long PURGE_INTERVAL = 3 * 60 * 60 * 1000L; +} diff --git a/src/main/java/com/threerings/user/depot/RewardCountRecord.java b/src/main/java/com/threerings/user/depot/RewardCountRecord.java new file mode 100644 index 0000000..51c5d94 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/RewardCountRecord.java @@ -0,0 +1,28 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Computed; + +/** + * Used to summarize rewards. + */ +@Computed(shadowOf=RewardInfoRecord.class) +public class RewardCountRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = RewardCountRecord.class; + public static final ColumnExp REWARD_ID = colexp(_R, "rewardId"); + public static final ColumnExp COUNT = colexp(_R, "count"); + // AUTO-GENERATED: FIELDS END + + /** The reward id. */ + public int rewardId; + + /** The number of matching records. */ + @Computed(fieldDefinition="count(*)") + public int count; +} diff --git a/src/main/java/com/threerings/user/depot/RewardInfoRecord.java b/src/main/java/com/threerings/user/depot/RewardInfoRecord.java new file mode 100644 index 0000000..996a1e3 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/RewardInfoRecord.java @@ -0,0 +1,89 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.GeneratedValue; +import com.samskivert.depot.annotation.GenerationType; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.util.StringUtil; + +/** + * A reward being offered. + */ +@Entity(name="REWARD_INFO") +public class RewardInfoRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = RewardInfoRecord.class; + public static final ColumnExp REWARD_ID = colexp(_R, "rewardId"); + public static final ColumnExp DESCRIPTION = colexp(_R, "description"); + public static final ColumnExp COUPON_CODE = colexp(_R, "couponCode"); + public static final ColumnExp DATA = colexp(_R, "data"); + public static final ColumnExp EXPIRATION = colexp(_R, "expiration"); + public static final ColumnExp MAX_ELIGIBLE_ID = colexp(_R, "maxEligibleId"); + public static final ColumnExp ACTIVATIONS = colexp(_R, "activations"); + public static final ColumnExp REDEMPTIONS = colexp(_R, "redemptions"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** Unique reward id. */ + @Id @Column(name="REWARD_ID") @GeneratedValue(strategy=GenerationType.AUTO) + public int rewardId; + + /** Reward description. */ + @Column(name="DESCRIPTION") + public String description; + + /** A coupon code required to activate this reward or null. */ + @Column(name="COUPON_CODE", nullable=true) + public String couponCode; + + /** Game specific reward data. */ + @Column(name="DATA") + public String data; + + /** Date when the reward expires. */ + @Column(name="EXPIRATION") + public Date expiration; + + /** Max userid that can redeem this reward. */ + @Column(name="MAX_ELIGIBLE_ID") + public int maxEligibleId; + + /** Number of activated rewards. */ + @Column(name="ACTIVATIONS") + public int activations; + + /** Number of claimed rewards. */ + @Column(name="REDEMPTIONS") + public int redemptions; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link RewardInfoRecord} + * with the supplied key values. + */ + public static Key getKey (int rewardId) + { + return newKey(_R, rewardId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(REWARD_ID); } + // AUTO-GENERATED: METHODS END + + @Override // from Object + public String toString () + { + return StringUtil.fieldsToString(this); + } +} diff --git a/src/main/java/com/threerings/user/depot/RewardRecord.java b/src/main/java/com/threerings/user/depot/RewardRecord.java new file mode 100644 index 0000000..e4315b7 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/RewardRecord.java @@ -0,0 +1,62 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * A persistent reward. + */ +@Entity(name="REWARD_RECORDS") +public class RewardRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = RewardRecord.class; + public static final ColumnExp REWARD_ID = colexp(_R, "rewardId"); + public static final ColumnExp ACCOUNT = colexp(_R, "account"); + public static final ColumnExp REDEEMER_IDENT = colexp(_R, "redeemerIdent"); + public static final ColumnExp PARAM = colexp(_R, "param"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** RewardInfo reward id. */ + @Id @Column(name="REWARD_ID") + public int rewardId; + + /** Account name. */ + @Id @Index(name="ixAccount") @Column(name="ACCOUNT", nullable=true) + public String account; + + /** Unique identification value. */ + @Index(name="ixRedeemerIdent") @Column(name="REDEEMER_IDENT", length=64, nullable=true) + public String redeemerIdent; + + /** + * A generic parameter string that can tell the reward handler more specifically when and what + * item, etc. to hand out for this instance of the reward. + */ + @Id @Column(name="PARAM", nullable=true) + public String param; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link RewardRecord} + * with the supplied key values. + */ + public static Key getKey (int rewardId, String account, String param) + { + return newKey(_R, rewardId, account, param); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(REWARD_ID, ACCOUNT, PARAM); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/RewardRepository.java b/src/main/java/com/threerings/user/depot/RewardRepository.java new file mode 100644 index 0000000..73d8144 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/RewardRepository.java @@ -0,0 +1,304 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; +import java.sql.Timestamp; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.List; +import java.util.Set; + +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import com.samskivert.depot.DepotRepository; +import com.samskivert.depot.DuplicateKeyException; +import com.samskivert.depot.KeySet; +import com.samskivert.depot.Ops; +import com.samskivert.depot.PersistenceContext; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.clause.FromOverride; +import com.samskivert.depot.clause.GroupBy; +import com.samskivert.depot.clause.OrderBy; +import com.samskivert.depot.clause.Where; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.expression.SQLExpression; +import com.samskivert.jdbc.ConnectionProvider; + +import com.threerings.user.OOOUserRepository; + +import static com.threerings.user.Log.log; + +/** + * Maintains persistent reward information. + */ +@Singleton +public class RewardRepository extends DepotRepository +{ + @Inject public RewardRepository (PersistenceContext ctx) + { + super(ctx); + } + + /** + * Creates the repository with the specified connection provider. + * + */ + public RewardRepository (ConnectionProvider provider) + { + super(new PersistenceContext(OOOUserRepository.USER_REPOSITORY_IDENT, provider, null)); + } + + /** + * Creates a new RewardInfo record in the database. info should have the + * description, data and expiration filled in. + */ + public void createReward (RewardInfoRecord info) + { + //info.maxEligibleId = getMaxUserId(); + store(info); + } + + /** + * Immediately expires a reward by setting the expiration to the current date then making a + * call to purgeExpiredRewards. + */ + public void expireReward (int rewardId) + { + updatePartial(RewardInfoRecord.getKey(rewardId), RewardInfoRecord.EXPIRATION, + new Date(System.currentTimeMillis())); + purgeExpiredRewards(); + } + + /** + * Attempt to activate the reward for an account. If this account has not already activated + * the reward, a new RewardRecord will be created. Returns true if a new RewardRecord is + * created, false otherwise. + */ + public boolean activateReward (int rewardId, String account) + { + return activateReward(rewardId, account, null); + } + + public boolean activateReward (int rewardId, String account, String param) + { + RewardRecord rr = new RewardRecord(); + rr.rewardId = rewardId; + rr.account = account; + rr.param = param == null ? "" : param; + try { + insert(rr); + } catch (DuplicateKeyException e) { + return false; + } + return true; + } + + /** + * Activates monthly rewards of the specified ID for the account between start and end. If they + * already have monthly rewards for this ID, will add new ones only at appropriate monthly + * intervals from the existing ones. + */ + public boolean activateMonthlyRewards ( + int rewardId, String account, java.util.Date start, java.util.Date end) + { + // Find the most recent reward this account already has of this type. + java.util.Date latest = null; + List matches = findAll(RewardRecord.class, + new Where(Ops.and(RewardRecord.REWARD_ID.eq(rewardId), + RewardRecord.ACCOUNT.eq(account)))); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + // Find the most recent reward this account already has of this type. + for (RewardRecord rec : matches) { + if (rec.param == null) { + log.warning("Missing monthly reward param: ", "rewardId", rewardId, + "account", account); + continue; + } + try { + java.util.Date thisDate = dateFormat.parse(rec.param); + if (latest == null || thisDate.after(latest)) { + latest = thisDate; + } + } catch (ParseException pe) { + log.warning("Bogus reward param: ", "rewardId", rewardId, + "account", account, "param", rec.param); + continue; + } + } + + // We use the maximum of our provided start time and a month-after-latest + // as the first time we'll input a reward. Note that this can result + // in no rewards being given. + Calendar startCal = Calendar.getInstance(); + if (latest != null) { + startCal.setTime(latest); + startCal.add(Calendar.MONTH, 1); + if (startCal.getTime().before(start)) { + startCal.setTime(start); + } + } else { + startCal.setTime(start); + } + + // Add in any appropriate rewards between the modified start and end. + while (startCal.getTime().before(end)) { + RewardRecord rr = new RewardRecord(); + rr.rewardId = rewardId; + rr.account = account; + rr.param = dateFormat.format(startCal.getTime()); + insert(rr); + startCal.add(Calendar.MONTH, 1); + } + + return true; + } + + /** + * Deactivates all rewards of the specified ID and account during the time window. + * Returns the number of rewards deleted. + */ + public int deactivateMonthlyRewards ( + int rewardId, String account, java.util.Date start, java.util.Date end) + { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String startStr = dateFormat.format(start); + String endStr = dateFormat.format(end); + + return deleteAll(RewardRecord.class, + new Where(Ops.and( + RewardRecord.REWARD_ID.eq(rewardId), + RewardRecord.ACCOUNT.eq(account), + RewardRecord.PARAM.greaterEq(startStr), + RewardRecord.PARAM.lessEq(endStr), + RewardRecord.REDEEMER_IDENT.isNull()))); + } + + /** + * Returns all currently active rewards. + */ + public List loadActiveRewards () + { + return findAll(RewardInfoRecord.class, + new Where(RewardInfoRecord.EXPIRATION.greaterThan( + new Timestamp(System.currentTimeMillis())))); + } + + /** + * Returns all rewards. + */ + public List loadRewards () + { + return findAll(RewardInfoRecord.class, OrderBy.descending(RewardInfoRecord.REWARD_ID)); + } + + /** + * Returns all RewardRecords that match the given account name and whose eligible + * date is in the past. The returned list will be sorted by rewardId. + */ + public List loadActivatedRewards (String account) + { + return findAll(RewardRecord.class, + new Where(RewardRecord.ACCOUNT.eq(account)), + OrderBy.ascending(RewardRecord.REWARD_ID)); + } + + /** + * Returns all RewardRecords that match either the account or redeemer identifier. + * The returned list will be sorted by rewardId. + */ + public List loadActivatedRewards (String account, String redeemerIdent) + { + return findAll(RewardRecord.class, + new Where(Ops.or(RewardRecord.ACCOUNT.eq(account), + RewardRecord.REDEEMER_IDENT.eq(redeemerIdent))), + OrderBy.ascending(RewardRecord.REWARD_ID).thenAscending(RewardRecord.PARAM)); + } + + /** + * Returns all RewardRecords for a specified rewardId that match + * either the account or redeemer identifier. + */ + public List loadActivatedReward ( + String account, String redeemerIdent, int rewardId) + { + return findAll(RewardRecord.class, + new Where(Ops.and(RewardRecord.REWARD_ID.eq(rewardId), + Ops.or(RewardRecord.ACCOUNT.eq(account), + RewardRecord.REDEEMER_IDENT.eq(redeemerIdent))))); + } + + /** + * Returns all RewardRecords for a specified rewardId and + * param that match either the account or redeemer identifier. + */ + public List loadActivatedReward ( + String account, String redeemerIdent, int rewardId, String param) + { + SQLExpression paramExp = param == null ? + RewardRecord.PARAM.isNull() : RewardRecord.PARAM.eq(param); + return findAll(RewardRecord.class, + new Where(Ops.and( + RewardRecord.REWARD_ID.eq(rewardId), + paramExp, + Ops.or( + RewardRecord.ACCOUNT.eq(account), + RewardRecord.REDEEMER_IDENT.eq(redeemerIdent))))); + } + + /** + * Updates the supplied RewardRecord with the indicated redeemerIdent and store it + * to the database. + */ + public void redeemReward (RewardRecord record, String redeemerIdent) + { + record.redeemerIdent = redeemerIdent; + update(record, RewardRecord.REDEEMER_IDENT); + } + + /** + * Summarizes the reward data and purges expired rewards. + */ + public void purgeExpiredRewards () + { + summarizeAndUpdate(null, RewardInfoRecord.ACTIVATIONS); + summarizeAndUpdate( + Ops.not(RewardRecord.REDEEMER_IDENT.isNull()), RewardInfoRecord.REDEMPTIONS); + deleteAll(RewardRecord.class, + KeySet.newKeySet(RewardRecord.class, findAllKeys(RewardRecord.class, true, + new FromOverride(RewardRecord.class, RewardInfoRecord.class), + new Where(Ops.and( + RewardRecord.REWARD_ID.eq(RewardInfoRecord.REWARD_ID), + RewardInfoRecord.EXPIRATION.lessEq(new Timestamp(System.currentTimeMillis())) + ))))); + } + + /** + * Summarizes the reward data and stores it in the reward info records. + */ + protected void summarizeAndUpdate (SQLExpression condition, ColumnExp column) + { + SQLExpression where = RewardInfoRecord.REWARD_ID.eq(RewardRecord.REWARD_ID); + if (condition != null) { + where = Ops.and(where, condition); + } + List counts = findAll(RewardCountRecord.class, + new FromOverride(RewardInfoRecord.class, RewardRecord.class), + new Where(where), + new GroupBy(RewardInfoRecord.REWARD_ID)); + for (RewardCountRecord record : counts) { + updatePartial(RewardInfoRecord.getKey(record.rewardId), column, record.count); + } + } + + @Override + protected void getManagedRecords (Set> classes) + { + classes.add(RewardRecord.class); + classes.add(RewardInfoRecord.class); + } +} diff --git a/src/main/java/com/threerings/user/depot/SessionRecord.java b/src/main/java/com/threerings/user/depot/SessionRecord.java new file mode 100644 index 0000000..cc41de1 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/SessionRecord.java @@ -0,0 +1,56 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.expression.ColumnExp; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; + +/** + * Maintains information on authenticated HTTP sessions. + */ +@Entity(name="sessions") +public class SessionRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = SessionRecord.class; + public static final ColumnExp AUTHCODE = colexp(_R, "authcode"); + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp EXPIRES = colexp(_R, "expires"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** A unique code that identifies this session. */ + @Id @Column(length=32) + public String authcode; + + /** The id of the user authenticated in this session. */ + @Index(name="userid_index") + public int userId; + + /** The date on which the session expires. */ + @Index(name="expires_index") + public Date expires; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link SessionRecord} + * with the supplied key values. + */ + public static Key getKey (String authcode) + { + return newKey(_R, authcode); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(AUTHCODE); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/TaintedIdentRecord.java b/src/main/java/com/threerings/user/depot/TaintedIdentRecord.java new file mode 100644 index 0000000..f038f06 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/TaintedIdentRecord.java @@ -0,0 +1,54 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Represents a row in the TAINTED_IDENTS table, Depot version. + */ +@Entity(name="TAINTED_IDENTS") +public class TaintedIdentRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = TaintedIdentRecord.class; + public static final ColumnExp MACH_IDENT = colexp(_R, "machIdent"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** A 'unique' id for a specific machine we have seen. */ + @Id @Column(name="MACH_IDENT") + public String machIdent; + + /** Blank constructor for the unserialization business. */ + public TaintedIdentRecord () + { + } + + /** A constructor that populates this record. */ + public TaintedIdentRecord (String machIdent) + { + this.machIdent = machIdent; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link TaintedIdentRecord} + * with the supplied key values. + */ + public static Key getKey (String machIdent) + { + return newKey(_R, machIdent); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(MACH_IDENT); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/UserIdentRecord.java b/src/main/java/com/threerings/user/depot/UserIdentRecord.java new file mode 100644 index 0000000..2c01d74 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/UserIdentRecord.java @@ -0,0 +1,61 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Extends the basic samskivert user record with special Three Rings business. + */ +@Entity(name="USER_IDENTS") +public class UserIdentRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = UserIdentRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp MACH_IDENT = colexp(_R, "machIdent"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 4; + + /** The id of the user in question. */ + @Id @Column(name="USER_ID") + public int userId; + + /** A 'unique' id for a specific machine we have seen the user come from. */ + @Id @Column(name="MACH_IDENT") @Index(name="ixMachIdent") + public String machIdent; + + public UserIdentRecord () + { + super(); + } + + public UserIdentRecord (int userId, String machIdent) + { + super(); + this.userId = userId; + this.machIdent = machIdent; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link UserIdentRecord} + * with the supplied key values. + */ + public static Key getKey (int userId, String machIdent) + { + return newKey(_R, userId, machIdent); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID, MACH_IDENT); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/UserInvitationRecord.java b/src/main/java/com/threerings/user/depot/UserInvitationRecord.java new file mode 100644 index 0000000..280cc6b --- /dev/null +++ b/src/main/java/com/threerings/user/depot/UserInvitationRecord.java @@ -0,0 +1,48 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.annotation.Index; +import com.samskivert.depot.expression.ColumnExp; + +/** + * Maps a multiple invitation record to a user. + */ +@Entity +public class UserInvitationRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = UserInvitationRecord.class; + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp INVITATION_ID = colexp(_R, "invitationId"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The user id. */ + @Id + public int userId; + + /** The invitation id. */ + @Index + public int invitationId; + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link UserInvitationRecord} + * with the supplied key values. + */ + public static Key getKey (int userId) + { + return newKey(_R, userId); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(USER_ID); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/depot/ValidateDepotRecord.java b/src/main/java/com/threerings/user/depot/ValidateDepotRecord.java new file mode 100644 index 0000000..bbd6932 --- /dev/null +++ b/src/main/java/com/threerings/user/depot/ValidateDepotRecord.java @@ -0,0 +1,85 @@ +// +// $Id$ + +package com.threerings.user.depot; + +import java.sql.Date; + +import com.samskivert.depot.Key; +import com.samskivert.depot.PersistentRecord; +import com.samskivert.depot.annotation.Column; +import com.samskivert.depot.annotation.Entity; +import com.samskivert.depot.annotation.Id; +import com.samskivert.depot.expression.ColumnExp; + +import com.threerings.user.ValidateRecord; + +/** + * Emulates {@link ValidateRecord} for the Depot. + */ +@Entity(name="penders") +public class ValidateDepotRecord extends PersistentRecord +{ + // AUTO-GENERATED: FIELDS START + public static final Class _R = ValidateDepotRecord.class; + public static final ColumnExp SECRET = colexp(_R, "secret"); + public static final ColumnExp USER_ID = colexp(_R, "userId"); + public static final ColumnExp PERSIST = colexp(_R, "persist"); + public static final ColumnExp INSERTED = colexp(_R, "inserted"); + // AUTO-GENERATED: FIELDS END + + public static final int SCHEMA_VERSION = 1; + + /** The secret key. */ + @Id @Column(length=32) + public String secret; + + /** The user id. */ + public int userId; + + /** The persist? */ + public boolean persist; + + /** The date inserted. */ + public Date inserted; + + /** + * Creates a ValidateDepotRecord from a ValidateRecord. + */ + public static ValidateDepotRecord fromValidateRecord (ValidateRecord vr) + { + ValidateDepotRecord record = new ValidateDepotRecord(); + record.secret = vr.secret; + record.userId = vr.userId; + record.persist = vr.persist; + record.inserted = vr.inserted; + return record; + } + + /** + * Returns a ValidateRecord version of this record. + */ + public ValidateRecord toValidateRecord () + { + ValidateRecord record = new ValidateRecord(); + record.secret = secret; + record.userId = userId; + record.persist = persist; + record.inserted = inserted; + return record; + } + + // AUTO-GENERATED: METHODS START + /** + * Create and return a primary {@link Key} to identify a {@link ValidateDepotRecord} + * with the supplied key values. + */ + public static Key getKey (String secret) + { + return newKey(_R, secret); + } + + /** Register the key fields in an order matching the getKey() factory. */ + static { registerKeyFields(SECRET); } + // AUTO-GENERATED: METHODS END +} diff --git a/src/main/java/com/threerings/user/tools/AccountActionTool.java b/src/main/java/com/threerings/user/tools/AccountActionTool.java new file mode 100644 index 0000000..495aee4 --- /dev/null +++ b/src/main/java/com/threerings/user/tools/AccountActionTool.java @@ -0,0 +1,68 @@ +// +// $Id$ + +package com.threerings.user.tools; + +import java.util.List; + +import com.samskivert.util.Config; + +import com.samskivert.jdbc.StaticConnectionProvider; + +import com.threerings.user.AccountAction; +import com.threerings.user.depot.AccountActionRepository; + +/** + * Provides command line inspection and processing of account actions. + */ +public class AccountActionTool +{ + public static void main (String[] args) + { + if (args.length == 0) { + System.err.println(USAGE); + System.exit(255); + } + + Config config = new Config("threerings"); + try { + AccountActionRepository repo = new AccountActionRepository( + new StaticConnectionProvider(config.getSubProperties("db"))); + + if (args[0].equals("list")) { + while (true) { + String server = (args.length > 1) ? args[1] : ""; + List actions = repo.getActions(server, 999); + for (int ii = 0; ii < actions.size(); ii++) { + System.out.println(actions.get(ii)); + } + try { Thread.sleep(1000L); } catch (Exception e) {} + } + + } else if (args[0].equals("prune")) { + repo.pruneActions(); + + } else if (args[0].equals("add")) { + if (args.length != 3) { + System.err.println(USAGE); + System.exit(255); + } + repo.addAction(args[1], Integer.parseInt(args[2])); + + } else if (args[0].equals("process")) { + if (args.length != 3) { + System.err.println(USAGE); + System.exit(255); + } + repo.noteProcessed(Integer.parseInt(args[1]), args[2]); + } + + } catch (Exception e) { + e.printStackTrace(System.err); + } + } + + protected static final String USAGE = + "Usage: AccountActionTool [prune|list|list server|" + + "add account_name action_type|process action_id server]"; +} diff --git a/src/main/java/com/threerings/user/tools/ActivateTesters.java b/src/main/java/com/threerings/user/tools/ActivateTesters.java new file mode 100644 index 0000000..f4cef73 --- /dev/null +++ b/src/main/java/com/threerings/user/tools/ActivateTesters.java @@ -0,0 +1,143 @@ +// +// $Id$ + +package com.threerings.user.tools; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; + +import com.samskivert.jdbc.StaticConnectionProvider; +import com.samskivert.net.MailUtil; +import com.samskivert.servlet.user.User; +import com.samskivert.util.ArrayUtil; +import com.samskivert.util.Config; + +import com.threerings.user.OOOUser; +import com.threerings.user.OOOUserRepository; + +/** + * A script for activating (or deactivating) the tester flag on users. + */ +public class ActivateTesters +{ + public static void main (String[] args) + { + if (args.length == 0 || + !(args[0].equals("activate") || args[0].equals("clear")) || + (args[0].equals("activate") && args.length < 3)) { + System.err.println(USAGE); + System.exit(255); + } + + Config config = new Config("threerings"); + try { + OOOUserRepository urepo = new OOOUserRepository( + new StaticConnectionProvider(config.getSubProperties("db"))); + + if (args[0].equals("clear")) { + clearTesters(urepo); + } else if (args[0].equals("activate")) { + activateTesters(urepo, Integer.parseInt(args[1]), args[2]); + } + + } catch (Exception e) { + e.printStackTrace(System.err); + } + } + + protected static void activateTesters ( + OOOUserRepository urepo, int count, String emailFile) + throws Exception + { + // load up the emails and randomize them + BufferedReader bin = new BufferedReader(new FileReader(emailFile)); + List elist = Lists.newArrayList(); + String email; + int rejected = 0; + while ((email = bin.readLine()) != null) { + if (!MailUtil.isValidAddress(email)) { + // System.err.println("Rejecting invalid address: " + email); + rejected++; + continue; + } + elist.add(email); + } + if (rejected > 0) { + System.err.println("Rejected " + rejected + + " invalid addresses, accepted " + + elist.size() + " valid addresses."); + } + String[] emails = elist.toArray(new String[elist.size()]); + ArrayUtil.shuffle(emails); + + int activated = 0, offset = 0; + while (activated < count && offset < emails.length) { + // grab the next N addresses from the list and see if any of them + // have an account that we can activate as a tester + StringBuilder where = new StringBuilder(); + for (int ii = offset, ll = Math.min(emails.length, offset+CHUNK); + ii < ll; ii++) { + if (where.length() > 0) { + where.append(", "); + } + where.append("'").append(emails[ii]).append("'"); + } + List users = urepo.lookupUsersWhere("email in (" + where + ")"); + + // filter out users that have multiple accounts; cheeky bastards + HashSet seen = Sets.newHashSet(), filter = Sets.newHashSet(); + for (User ruser : users) { + OOOUser user = (OOOUser)ruser; + if (seen.contains(user.email)) { + filter.add(user.email); + } else { + seen.add(user.email); + } + } + for (Iterator iter = users.iterator(); iter.hasNext(); ) { + OOOUser user = (OOOUser)iter.next(); + if (filter.contains(user.email)) { + iter.remove(); + } + } + + // now mark anyone that made it through the guantlet as a tester + for (Iterator iter = users.iterator(); iter.hasNext(); ) { + OOOUser user = (OOOUser)iter.next(); + if (user.holdsToken(OOOUser.TESTER)) { + continue; + } + user.addToken(OOOUser.TESTER); + System.out.println(user.username + " " + user.email); + urepo.updateUser(user); + activated++; + } + offset += CHUNK; + } + + System.err.println("Activated " + activated + " accounts."); + } + + protected static void clearTesters (OOOUserRepository urepo) + throws Exception + { + List users = urepo.lookupUsersWhere("HEX(tokens) like '%04%'"); + System.err.println("Clearing " + users.size() + " testers."); + for (int ii = 0, ll = users.size(); ii < ll; ii++) { + OOOUser user = (OOOUser)users.get(ii); + user.removeToken(OOOUser.TESTER); + urepo.updateUser(user); + } + } + + protected static final String USAGE = + "Usage: ActivateTesters [activate count emails.txt | clear]"; + + protected static final int CHUNK = 25; +} diff --git a/src/main/java/com/threerings/user/tools/UserTool.java b/src/main/java/com/threerings/user/tools/UserTool.java new file mode 100644 index 0000000..32e3654 --- /dev/null +++ b/src/main/java/com/threerings/user/tools/UserTool.java @@ -0,0 +1,41 @@ +// +// $Id$ + +package com.threerings.user.tools; + +import com.samskivert.jdbc.StaticConnectionProvider; +import com.samskivert.util.Config; + +import com.threerings.user.OOOUserRepository; + +/** + * Provides command line inspection and processing of account actions. + */ +public class UserTool +{ + public static void main (String[] args) + { + if (args.length == 0) { + System.err.println(USAGE); + System.exit(255); + } + + Config config = new Config("threerings"); + try { + OOOUserRepository repo = new OOOUserRepository( + new StaticConnectionProvider(config.getSubProperties("db"))); + + if (args[0].equals("prune")) { + repo.pruneUsers(PRUNE_DAYS); + } + + } catch (Exception e) { + e.printStackTrace(System.err); + } + } + + protected static final String USAGE = "Usage: UserTool [prune]"; + + /** Users that have been purged from all other games for 30 days are toast. */ + protected static final int PRUNE_DAYS = 30; +}