// // $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 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 to 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 to 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 to 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 to 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 ({@code 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 ({@code 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 ({@code 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 ({@code 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). */ public ValidateRecord createValidateRecord (int userId, boolean persist) throws PersistenceException { // delete any old validate mappings for the user update("delete from penders where userId = '" + userId + "'"); // create a new one and insert it into the database ValidateRecord rec = ValidateRecord.create(userId, persist); 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 {@code 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(128) 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, ""); // give the password a longer length to allow more secure hashing int passwordLength = JDBCUtil.getColumnSize(conn, "users", "password"); if (passwordLength < 128) { JDBCUtil.changeColumn(conn, "users", "password", "password VARCHAR(128) NOT NULL"); } 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"), }; }