diff --git a/src/main/java/fr/xephi/authme/command/CommandInitializer.java b/src/main/java/fr/xephi/authme/command/CommandInitializer.java index dbce87332..2cd0fc600 100644 --- a/src/main/java/fr/xephi/authme/command/CommandInitializer.java +++ b/src/main/java/fr/xephi/authme/command/CommandInitializer.java @@ -17,6 +17,7 @@ import fr.xephi.authme.command.executable.authme.PurgeBannedPlayersCommand; import fr.xephi.authme.command.executable.authme.PurgeCommand; import fr.xephi.authme.command.executable.authme.PurgeLastPositionCommand; import fr.xephi.authme.command.executable.authme.PurgePlayerCommand; +import fr.xephi.authme.command.executable.authme.RecentPlayersCommand; import fr.xephi.authme.command.executable.authme.RegisterAdminCommand; import fr.xephi.authme.command.executable.authme.ReloadCommand; import fr.xephi.authme.command.executable.authme.SetEmailCommand; @@ -433,6 +434,15 @@ public class CommandInitializer { .executableCommand(MessagesCommand.class) .register(); + CommandDescription.builder() + .parent(authmeBase) + .labels("recent") + .description("See players who have recently logged in") + .detailedDescription("Shows the last players that have logged in.") + .permission(AdminPermission.SEE_RECENT_PLAYERS) + .executableCommand(RecentPlayersCommand.class) + .register(); + CommandDescription.builder() .parent(authmeBase) .labels("debug", "dbg") diff --git a/src/main/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommand.java b/src/main/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommand.java new file mode 100644 index 000000000..1666acb15 --- /dev/null +++ b/src/main/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommand.java @@ -0,0 +1,50 @@ +package fr.xephi.authme.command.executable.authme; + +import com.google.common.annotations.VisibleForTesting; +import fr.xephi.authme.command.ExecutableCommand; +import fr.xephi.authme.data.auth.PlayerAuth; +import fr.xephi.authme.datasource.DataSource; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; + +import javax.inject.Inject; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static java.time.Instant.ofEpochMilli; + +/** + * Command showing the most recent logged in players. + */ +public class RecentPlayersCommand implements ExecutableCommand { + + /** DateTime formatter, producing Strings such as "10:42 AM, 11 Jul". */ + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("hh:mm a, dd MMM"); + + @Inject + private DataSource dataSource; + + @Override + public void executeCommand(CommandSender sender, List arguments) { + List recentPlayers = dataSource.getRecentlyLoggedInPlayers(); + + sender.sendMessage(ChatColor.BLUE + "[AuthMe] Recently logged in players"); + for (PlayerAuth auth : recentPlayers) { + sender.sendMessage(formatPlayerMessage(auth)); + } + } + + @VisibleForTesting + ZoneId getZoneId() { + return ZoneId.systemDefault(); + } + + private String formatPlayerMessage(PlayerAuth auth) { + LocalDateTime lastLogin = LocalDateTime.ofInstant(ofEpochMilli(auth.getLastLogin()), getZoneId()); + String lastLoginText = DATE_FORMAT.format(lastLogin); + + return "- " + auth.getRealName() + " (" + lastLoginText + " with IP " + auth.getLastIp() + ")"; + } +} diff --git a/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java b/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java index ac2eaf12c..39f04a53c 100644 --- a/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java +++ b/src/main/java/fr/xephi/authme/datasource/CacheDataSource.java @@ -263,6 +263,11 @@ public class CacheDataSource implements DataSource { .collect(Collectors.toList()); } + @Override + public List getRecentlyLoggedInPlayers() { + return source.getRecentlyLoggedInPlayers(); + } + @Override public void invalidateCache(String playerName) { cachedAuths.invalidate(playerName); diff --git a/src/main/java/fr/xephi/authme/datasource/DataSource.java b/src/main/java/fr/xephi/authme/datasource/DataSource.java index 715cb0598..6f97951db 100644 --- a/src/main/java/fr/xephi/authme/datasource/DataSource.java +++ b/src/main/java/fr/xephi/authme/datasource/DataSource.java @@ -225,6 +225,13 @@ public interface DataSource extends Reloadable { */ List getAllAuths(); + /** + * Returns the last ten players who have recently logged in (first ten players with highest last login date). + * + * @return the 10 last players who last logged in + */ + List getRecentlyLoggedInPlayers(); + /** * Reload the data source. */ diff --git a/src/main/java/fr/xephi/authme/datasource/FlatFile.java b/src/main/java/fr/xephi/authme/datasource/FlatFile.java index 0ace15b1d..d234da556 100644 --- a/src/main/java/fr/xephi/authme/datasource/FlatFile.java +++ b/src/main/java/fr/xephi/authme/datasource/FlatFile.java @@ -393,6 +393,11 @@ public class FlatFile implements DataSource { throw new UnsupportedOperationException("Flat file no longer supported"); } + @Override + public List getRecentlyLoggedInPlayers() { + throw new UnsupportedOperationException("Flat file no longer supported"); + } + /** * Creates a PlayerAuth object from the read data. * diff --git a/src/main/java/fr/xephi/authme/datasource/MySQL.java b/src/main/java/fr/xephi/authme/datasource/MySQL.java index 98bbaa68b..eb6019ca2 100644 --- a/src/main/java/fr/xephi/authme/datasource/MySQL.java +++ b/src/main/java/fr/xephi/authme/datasource/MySQL.java @@ -716,6 +716,22 @@ public class MySQL implements DataSource { return players; } + @Override + public List getRecentlyLoggedInPlayers() { + List players = new ArrayList<>(); + String sql = "SELECT * FROM " + tableName + " ORDER BY " + col.LAST_LOGIN + " DESC LIMIT 10;"; + try (Connection con = getConnection(); + Statement st = con.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + while (rs.next()) { + players.add(buildAuthFromResultSet(rs)); + } + } catch (SQLException e) { + logSqlException(e); + } + return players; + } + private PlayerAuth buildAuthFromResultSet(ResultSet row) throws SQLException { String salt = col.SALT.isEmpty() ? null : row.getString(col.SALT); int group = col.GROUP.isEmpty() ? -1 : row.getInt(col.GROUP); diff --git a/src/main/java/fr/xephi/authme/datasource/SQLite.java b/src/main/java/fr/xephi/authme/datasource/SQLite.java index 8df1a0330..4924e0cc0 100644 --- a/src/main/java/fr/xephi/authme/datasource/SQLite.java +++ b/src/main/java/fr/xephi/authme/datasource/SQLite.java @@ -639,6 +639,20 @@ public class SQLite implements DataSource { return players; } + @Override + public List getRecentlyLoggedInPlayers() { + List players = new ArrayList<>(); + String sql = "SELECT * FROM " + tableName + " ORDER BY " + col.LAST_LOGIN + " DESC LIMIT 10;"; + try (Statement st = con.createStatement(); ResultSet rs = st.executeQuery(sql)) { + while (rs.next()) { + players.add(buildAuthFromResultSet(rs)); + } + } catch (SQLException e) { + logSqlException(e); + } + return players; + } + private PlayerAuth buildAuthFromResultSet(ResultSet row) throws SQLException { String salt = !col.SALT.isEmpty() ? row.getString(col.SALT) : null; diff --git a/src/main/java/fr/xephi/authme/permission/AdminPermission.java b/src/main/java/fr/xephi/authme/permission/AdminPermission.java index 14baf3ae9..7664e1436 100644 --- a/src/main/java/fr/xephi/authme/permission/AdminPermission.java +++ b/src/main/java/fr/xephi/authme/permission/AdminPermission.java @@ -50,6 +50,11 @@ public enum AdminPermission implements PermissionNode { */ GET_IP("authme.admin.getip"), + /** + * Administrator command to see the last recently logged in players. + */ + SEE_RECENT_PLAYERS("authme.admin.seerecent"), + /** * Administrator command to teleport to the AuthMe spawn. */ diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9014476f6..0f74a3ac3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -17,7 +17,7 @@ softdepend: commands: authme: description: AuthMe op commands - usage: /authme register|unregister|forcelogin|password|lastlogin|accounts|email|setemail|getip|spawn|setspawn|firstspawn|setfirstspawn|purge|purgeplayer|backup|resetpos|purgebannedplayers|switchantibot|reload|version|converter|messages|debug + usage: /authme register|unregister|forcelogin|password|lastlogin|accounts|email|setemail|getip|spawn|setspawn|firstspawn|setfirstspawn|purge|purgeplayer|backup|resetpos|purgebannedplayers|switchantibot|reload|version|converter|messages|recent|debug email: description: Add email or recover password usage: /email show|add|change|recover|code|setpassword @@ -74,6 +74,7 @@ permissions: authme.admin.register: true authme.admin.reload: true authme.admin.seeotheraccounts: true + authme.admin.seerecent: true authme.admin.setfirstspawn: true authme.admin.setspawn: true authme.admin.spawn: true @@ -134,6 +135,9 @@ permissions: authme.admin.seeotheraccounts: description: Permission to see the other accounts of the players that log in. default: op + authme.admin.seerecent: + description: Administrator command to see the last recently logged in players. + default: op authme.admin.setfirstspawn: description: Administrator command to set the first AuthMe spawn. default: op diff --git a/src/test/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommandTest.java b/src/test/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommandTest.java new file mode 100644 index 000000000..eac5f8fb9 --- /dev/null +++ b/src/test/java/fr/xephi/authme/command/executable/authme/RecentPlayersCommandTest.java @@ -0,0 +1,61 @@ +package fr.xephi.authme.command.executable.authme; + +import fr.xephi.authme.data.auth.PlayerAuth; +import fr.xephi.authme.datasource.DataSource; +import org.bukkit.command.CommandSender; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Collections; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +/** + * Test for {@link RecentPlayersCommand}. + */ +@RunWith(MockitoJUnitRunner.class) +public class RecentPlayersCommandTest { + + @InjectMocks + @Spy + private RecentPlayersCommand command; + + @Mock + private DataSource dataSource; + + @Test + public void shouldShowRecentPlayers() { + // given + PlayerAuth auth1 = PlayerAuth.builder() + .name("hannah").realName("Hannah").lastIp("11.11.11.11") + .lastLogin(1510387755000L) // 11/11/2017 @ 8:09am + .build(); + PlayerAuth auth2 = PlayerAuth.builder() + .name("matt").realName("MATT").lastIp("22.11.22.33") + .lastLogin(1510269301000L) // 11/09/2017 @ 11:15pm + .build(); + doReturn(ZoneId.of("UTC")).when(command).getZoneId(); + given(dataSource.getRecentlyLoggedInPlayers()).willReturn(Arrays.asList(auth1, auth2)); + + CommandSender sender = mock(CommandSender.class); + + // when + command.executeCommand(sender, Collections.emptyList()); + + // then + verify(sender).sendMessage(argThat(containsString("Recently logged in players"))); + verify(sender).sendMessage("- Hannah (08:09 AM, 11 Nov with IP 11.11.11.11)"); + verify(sender).sendMessage("- MATT (11:15 PM, 09 Nov with IP 22.11.22.33)"); + } +} diff --git a/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java b/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java index f72c92ae7..530ab56be 100644 --- a/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java +++ b/src/test/java/fr/xephi/authme/datasource/AbstractDataSourceIntegrationTest.java @@ -1,5 +1,6 @@ package fr.xephi.authme.datasource; +import com.google.common.collect.Lists; import fr.xephi.authme.data.auth.PlayerAuth; import fr.xephi.authme.security.crypts.HashedPassword; import org.junit.Test; @@ -467,4 +468,30 @@ public abstract class AbstractDataSourceIntegrationTest { assertThat(dataSource.hasSession("user"), equalTo(true)); assertThat(dataSource.hasSession("nonExistentName"), equalTo(false)); } + + @Test + public void shouldGetRecentlyLoggedInPlayers() { + // given + DataSource dataSource = getDataSource(); + String[] names = {"user3", "user8", "user2", "user4", "user7", + "user11", "user14", "user12", "user18", "user16", + "user28", "user29", "user22", "user20", "user24"}; + long timestamp = 1461024000; // 2016-04-19 00:00:00 + for (int i = 0; i < names.length; ++i) { + PlayerAuth auth = PlayerAuth.builder().name(names[i]) + .registrationDate(1234567) + .lastLogin(timestamp + i * 3600) + .build(); + dataSource.saveAuth(auth); + dataSource.updateSession(auth); + } + + // when + List recentPlayers = dataSource.getRecentlyLoggedInPlayers(); + + // then + assertThat(Lists.transform(recentPlayers, PlayerAuth::getNickname), + contains("user24", "user20", "user22", "user29", "user28", + "user16", "user18", "user12", "user14", "user11")); + } }