diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7dfc609 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,68 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +tab_width = 2 + +[.gitignore] +max_line_length = unset + +[*.md] +max_line_length = unset + +[*.feature] +tab_width = 4 + +[*.gsp] +indent_size = 4 +tab_width = 4 + +[*.haml] +tab_width = 4 + +[*.less] +tab_width = 4 + +[*.styl] +tab_width = 4 + +[.editorconfig] +max_line_length = unset + +[{*.as,*.js2,*.es}] +indent_size = 4 +tab_width = 4 + +[{*.cfml,*.cfm,*.cfc}] +indent_size = 4 +tab_width = 4 + +[{*.cjs,*.js}] +max_line_length = 80 + +[{*.gradle,*.groovy,*.gant,*.gdsl,*.gy,*.gson}] +indent_size = 4 +tab_width = 4 + +[{*.jspx,*.tagx}] +indent_size = 4 +tab_width = 4 + +[{*.kts,*.kt}] +indent_size = 4 +tab_width = 4 + +[{*.vsl,*.vm,*.ft}] +indent_size = 4 +tab_width = 4 + +[{*.xjsp,*.tag,*.jsf,*.jsp,*.jspf,*.tagf}] +indent_size = 4 +tab_width = 4 + +[{*.yml,*.yaml}] +tab_width = 4 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17d2489..4f5f0f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,8 @@ jobs: distribution: 'microsoft' - name: make gradle wrapper executable run: chmod +x ./gradlew + - name: gradle setup + uses: gradle/actions/setup-gradle@v4 - name: build run: ./gradlew build -Pversion=${{ needs.release-dry.outputs.version }} - name: capture build artifacts @@ -93,6 +95,27 @@ jobs: run: | minecraft_version=$(grep "^minecraft_version=" "gradle.properties" | cut -d'=' -f2) echo "MINECRAFT_VERSION=$minecraft_version" >> $GITHUB_ENV + last_minecraft_version=$(grep "^last_minecraft_version=" "gradle.properties" | cut -d'=' -f2) + echo "LAST_MINECRAFT_VERSION=$last_minecraft_version" >> $GITHUB_ENV + - name: Generate Minecraft versions JSON + run: | + if [[ -z "$LAST_MINECRAFT_VERSION" ]]; then + LAST_MINECRAFT_VERSION=$(curl -s https://piston-meta.mojang.com/mc/game/version_manifest_v2.json | jq -r '.latest.release') + fi + + MAJOR_MINOR=$(echo "$MINECRAFT_VERSION" | cut -d. -f1,2) + START_PATCH=$(echo "$MINECRAFT_VERSION" | cut -d. -f3) + END_PATCH=$(echo "$LAST_MINECRAFT_VERSION" | cut -d. -f3) + + seq $START_PATCH $END_PATCH | awk -v mm="$MAJOR_MINOR" '{print mm "." $1}' | jq -R . | jq -s -c . > versions.json + + - name: Load generated versions into env var + run: | + VERSIONS=$(jq -r '.[]' versions.json | paste -sd '\n' -) + echo "MINECRAFT_VERSIONS<> $GITHUB_ENV + echo "$VERSIONS" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + - name: Upload to Modrinth if: steps.semantic-release.outputs.new_release_version != '' uses: cloudnode-pro/modrinth-publish@2.0.0 @@ -105,5 +128,5 @@ jobs: loaders: |- fabric game-versions: |- - ${{ env.MINECRAFT_VERSION }} + ${{ env.MINECRAFT_VERSIONS }} files: build/libs/AltX-fabric-${{ steps.semantic-release.outputs.new_release_version }}.jar diff --git a/.gitignore b/.gitignore index c476faf..8c6cb44 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ out/ classes/ +.factorypath # eclipse diff --git a/build.gradle b/build.gradle index 40a3d4c..5355229 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,24 @@ base { archivesName = project.archives_base_name } +configurations { + embed + compileOnly.extendsFrom(embed) +} + +shadowJar { + configurations = [project.configurations.embed] + exclude('META-INF/services/**') + relocate "com.moandjiezana", "dcshadow.com.moandjiezana" +} + repositories { + mavenCentral() + maven { + url = uri("https://repo.opencollab.dev/main/") + } + maven { url 'https://jitpack.io' } + // Add repositories to retrieve artifacts from in here. // You should only use this when depending on other mods because // Loom adds the essential maven repositories to download Minecraft and libraries from automatically. @@ -23,7 +40,7 @@ loom { splitEnvironmentSourceSets() mods { - "modid" { + "altx" { sourceSet sourceSets.main sourceSet sourceSets.client } @@ -46,19 +63,36 @@ dependencies { mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" - // Fabric API. This is technically optional, but you probably want it anyway. modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" + include(modImplementation("me.lucko:fabric-permissions-api:${project.fabric_permission_api_version}")) - include(modImplementation("me.lucko:fabric-permissions-api:${project.fabric_permission_api_version}")) + embed implementation("club.minnced:discord-webhooks:${project.minnced_discord_webhook_version}") + + // config + embed implementation("de.erdbeerbaerlp:toml4j:dd60f51") + + // integrations + compileOnly "org.geysermc.floodgate:api:${project.floodgate_api_version}" + compileOnly "com.github.OrdinarySMP:DiscordJustSync:${project.justsync_version}" - embed implementation("club.minnced:discord-webhooks:${project.minnced_discord_webhook_version}") } +tasks.build.dependsOn(tasks.shadowJar) + processResources { - inputs.property "version", project.version + def minecraft_version = project.minecraft_version + if (project.last_minecraft_version != "") { + minecraft_version = minecraft_version + " <=" + project.last_minecraft_version + } + + inputs.property "version", project.version + inputs.property "minecraft_version", minecraft_version + inputs.property "loader_version", project.loader_version filesMatching("fabric.mod.json") { - expand "version": project.version + expand "version": project.version, + "loader_version": project.loader_version, + "minecraft_version": minecraft_version } } @@ -66,6 +100,46 @@ tasks.withType(JavaCompile).configureEach { it.options.release = 21 } +tasks.register("prodServer", net.fabricmc.loom.task.prod.ServerProductionRunTask) { + def version = (project.hasProperty('version') && project.property('version') != 'unspecified') + ? project.property('version'): project.minecraft_version + + def fabricApiMetadataUrl = 'https://maven.fabricmc.net/net/fabricmc/fabric-api/fabric-api/maven-metadata.xml' + + def latestFabricApiVersion = null + try { + def xmlText = new URL(fabricApiMetadataUrl).text + def parsedXml = new XmlSlurper().parseText(xmlText) + def matchingVersions = parsedXml.versioning.versions.version.findAll { + v -> v.text().endsWith("+${version}") + } + + if (!matchingVersions.isEmpty()) { + latestFabricApiVersion = matchingVersions[-1].text() + } + + } catch (Exception e) { + logger.warn("Failed to fetch or parse Fabric API metadata, falling back to default version: $e") + latestFabricApiVersion = project.fabric_version + version = project.minecraft_version + } + + if (latestFabricApiVersion == null) { + throw new GradleException("No Fabric API version found for specified Minecraft version: ${version}") + } + + configurations { + register("runtimeMods") + } + + dependencies.add("runtimeMods", "net.fabricmc.fabric-api:fabric-api:${latestFabricApiVersion}") + mods.from(configurations.named("runtimeMods")) + + installerVersion = "1.0.1" + loaderVersion = project.loader_version + minecraftVersion = version +} + java { // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task // if it is present. @@ -76,6 +150,15 @@ java { targetCompatibility = JavaVersion.VERSION_21 } +remapJar { + // wait until the shadowJar is done + dependsOn(shadowJar) + mustRunAfter(shadowJar) + // Set the input jar for the task. Here use the shadow Jar that include the .class of the transitive dependency + inputFile = file(shadowJar.archiveFile) +} + + jar { from("LICENSE") { rename { "${it}_${project.base.archivesName.get()}"} @@ -90,6 +173,11 @@ remapJar { inputFile = file(shadowJar.archiveFile) } +artifacts { + archives tasks.shadowJar +} + + // configure the maven publication publishing { publications { diff --git a/gradle.properties b/gradle.properties index 6c5006d..f0b853c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,16 +4,20 @@ org.gradle.parallel=true # Fabric Properties # check these on https://fabricmc.net/develop -minecraft_version=1.21.8 -yarn_mappings=1.21.8+build.1 +minecraft_version=1.21.6 +last_minecraft_version= +yarn_mappings=1.21.6+build.1 loader_version=0.16.14 # Mod Properties maven_group=com.xadale archives_base_name=AltX-fabric -# Dependencies -fabric_version=0.129.0+1.21.8 +# Mod Dependencies +fabric_version=0.127.0+1.21.6 fabric_permission_api_version=0.4.0 +floodgate_api_version=2.2.4-SNAPSHOT +justsync_version=master-SNAPSHOT +# Dependencies minnced_discord_webhook_version=0.8.4 diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/xadale/playerlogger/Commands.java b/src/main/java/com/xadale/playerlogger/Commands.java index d842cc9..50e9da2 100644 --- a/src/main/java/com/xadale/playerlogger/Commands.java +++ b/src/main/java/com/xadale/playerlogger/Commands.java @@ -1,8 +1,13 @@ package com.xadale.playerlogger; import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.xadale.playerlogger.commands.AuthorizeAltsCommand; import com.xadale.playerlogger.commands.HandleAltsCommand; import com.xadale.playerlogger.commands.ListIpsWithMultiplePlayers; +import java.util.concurrent.CompletableFuture; import me.lucko.fabric.api.permissions.v0.Permissions; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.minecraft.server.command.CommandManager; @@ -12,12 +17,6 @@ public class Commands { - private final PlayerLogger altx; - - public Commands(PlayerLogger altx) { - this.altx = altx; - } - public void register() { // Register the command CommandRegistrationCallback.EVENT.register( @@ -42,7 +41,9 @@ public void register() { if (Permissions.check(source, "altx.trace", 4)) { help.append( - "\n§b/altx trace §fShows all players on given players IP address"); + "\n" + + "§b/altx trace §fShows all players on given players IP" + + " address"); if (Permissions.check(source, "altx.viewips", 4)) { help.append( @@ -63,29 +64,12 @@ public void register() { .requires(Permissions.require("altx.trace", 4)) .then( CommandManager.argument("query", StringArgumentType.string()) - .suggests( - (context, builder) -> { - String partialQuery = - builder.getRemaining(); // Get the current typed string - for (ServerPlayerEntity player : - context - .getSource() - .getServer() - .getPlayerManager() - .getPlayerList()) { - String playerName = player.getName().getString(); - if (playerName - .toLowerCase() - .startsWith(partialQuery.toLowerCase())) { - builder.suggest(playerName); - } - } - return builder.buildFuture(); - }) + .suggests((context, builder) -> extracted(context, builder)) .executes( (context) -> HandleAltsCommand.execute( - context, this.altx.getLogFile())))) + context, + PlayerLogger.getInstance().getIpAssRepository())))) // Command list .then( @@ -94,7 +78,46 @@ public void register() { .executes( (context) -> ListIpsWithMultiplePlayers.execute( - context, this.altx.getLogFile())))); + context, PlayerLogger.getInstance().getIpAssRepository()))) + .then( + CommandManager.literal("reload") + .requires(Permissions.require("altx.reload", 4)) + .executes( + (context) -> { + PlayerLogger.getInstance().reloadConfig(); + return 1; + })) + .then( + CommandManager.literal("authorize") + .then( + CommandManager.literal("add") + .requires(Permissions.require("altx.authorize", 4)) + .then( + CommandManager.argument( + "player1", StringArgumentType.string()) + .suggests(this::extracted) + .then( + CommandManager.argument( + "player2", StringArgumentType.string()) + .suggests(this::extracted) + .executes(AuthorizeAltsCommand::execute)))) + .then( + CommandManager.literal("list") + .requires(Permissions.require("altx.authorize", 4)) + .executes(AuthorizeAltsCommand::listAuthorizedExecute)))); }); } + + private CompletableFuture extracted( + CommandContext context, SuggestionsBuilder builder) { + String partialQuery = builder.getRemaining(); // Get the current typed string + for (ServerPlayerEntity player : + context.getSource().getServer().getPlayerManager().getPlayerList()) { + String playerName = player.getName().getString(); + if (playerName.toLowerCase().startsWith(partialQuery.toLowerCase())) { + builder.suggest(playerName); + } + } + return builder.buildFuture(); + } } diff --git a/src/main/java/com/xadale/playerlogger/IpLogger.java b/src/main/java/com/xadale/playerlogger/IpLogger.java new file mode 100644 index 0000000..f3ab963 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/IpLogger.java @@ -0,0 +1,20 @@ +package com.xadale.playerlogger; + +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.repositories.IpAssRepository; +import java.util.Optional; +import java.util.UUID; + +public class IpLogger { + + public static void handleJoin(String ip, UUID uuid) { + IpAssRepository ipAssDataRepository = PlayerLogger.getInstance().getIpAssRepository(); + Optional ipAss = ipAssDataRepository.get(ip); + if (ipAss.isEmpty()) { + ipAssDataRepository.add(new IpAss(ip, uuid)); + return; + } + ipAss.get().addUUid(uuid); + NotificationHandler.handleNotif(ipAss.get(), uuid); + } +} diff --git a/src/main/java/com/xadale/playerlogger/NotificationHandler.java b/src/main/java/com/xadale/playerlogger/NotificationHandler.java new file mode 100644 index 0000000..70944dc --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/NotificationHandler.java @@ -0,0 +1,171 @@ +package com.xadale.playerlogger; + +import com.xadale.playerlogger.compat.JustSyncIntegration; +import com.xadale.playerlogger.data.AuthorizedAccounts; +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.data.LastReadNotif; +import com.xadale.playerlogger.data.Notif; +import com.xadale.playerlogger.repositories.AuthorizedAccountsRepository; +import com.xadale.playerlogger.repositories.NotifRepository; +import java.io.BufferedWriter; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.fabricmc.fabric.api.networking.v1.PacketSender; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +public class NotificationHandler { + + public static void handleNotif(IpAss ipAss, UUID uuid) { + if (!PlayerLogger.getInstance().getConfig().altNotifs.enableNotifs) { + return; + } + List uuids = ipAss.getUuids(); + List linkedUuids = JustSyncIntegration.getIntegration().getRelatedUuids(uuid); + + AuthorizedAccountsRepository authAltsRepo = + PlayerLogger.getInstance().getAuthorizedAccountsRepository(); + Optional authorizedAccountsOptional = authAltsRepo.get(uuid); + List filteredUuids = new ArrayList<>(); + if (authorizedAccountsOptional.isEmpty()) { + filteredUuids = uuids.stream().filter(u -> !linkedUuids.contains(u)).toList(); + } else { + List authorizedAccounts = authorizedAccountsOptional.get().getUuids(); + filteredUuids = + uuids.stream() + .filter(u -> !linkedUuids.contains(u)) + .filter(u -> !authorizedAccounts.contains(u)) + .toList(); + } + if (filteredUuids.isEmpty()) { + return; + } + String message = getMessage(uuid, filteredUuids); + WebhookMessageSender.sendMessage(message); + + PlayerLogger.getInstance() + .getServer() + .getPlayerManager() + .getPlayerList() + .forEach( + player -> { + if (Permissions.check(player, "altx.notify", 4)) { + player.sendMessage(Text.literal(message).formatted(Formatting.RED), false); + PlayerLogger.getInstance() + .getLastReadNotifRepository() + .get(player.getUuid()) + .ifPresent(lastReadNotif -> lastReadNotif.setLastNotifId(getLastNotifId() + 1)); + } + }); + + String notificationLog = + PlayerLogger.getConfigFolder() + .resolve(PlayerLogger.modId + ".notifications.log") + .toString(); + try (BufferedWriter output = new BufferedWriter(new FileWriter(notificationLog, true))) { + output.append(LocalDateTime.now().toString()).append(String.valueOf(' ')); + output.append(message); + output.newLine(); + } catch (IOException ignored) { + } + + Notif notif = new Notif(getLastNotifId() + 1, uuid, filteredUuids, LocalDateTime.now()); + PlayerLogger.getInstance().getNotifRepository().add(notif); + } + + public static void onJoin( + ServerPlayNetworkHandler handler, PacketSender sender, MinecraftServer server) { + ServerPlayerEntity player = handler.getPlayer(); + + // has permission for notifications + if (!Permissions.check(player, "altx.notify", 4)) { + return; + } + + // if not already seen notifications before (joined with permission before) + // create last read tracking object + Optional lastReadNotif = + PlayerLogger.getInstance().getLastReadNotifRepository().get(player.getUuid()); + if (lastReadNotif.isEmpty()) { + LastReadNotif newLastReadNotif = new LastReadNotif(player.getUuid(), getLastNotifId()); + PlayerLogger.getInstance().getLastReadNotifRepository().add(newLastReadNotif); + return; + } + + if (!PlayerLogger.getInstance().getConfig().altNotifs.showMissedNotifsOnJoin) { + return; + } + + // show missed notifications + List missedNotifs = + getMissedNotifs( + lastReadNotif.get().getLastNotifId(), + PlayerLogger.getInstance().getConfig().altNotifs.notificationPeriod); + for (Notif notif : missedNotifs) { + player.sendMessage( + Text.literal(getMessage(notif.getUuid(), notif.getAlts())).formatted(Formatting.RED), + false); + } + + lastReadNotif.get().setLastNotifId(getLastNotifId()); + } + + public static List getMissedNotifs(long afterId, int hours) { + LocalDateTime cutoff = LocalDateTime.now().minusHours(hours); + NotifRepository repository = PlayerLogger.getInstance().getNotifRepository(); + + return repository + .getAll() + .filter(notif -> notif.getId() > afterId) + .filter(notif -> notif.getTime() != null && !notif.getTime().isBefore(cutoff)) + .toList(); + } + + public static String getMessage(UUID uuid, List uuids) { + return "Player " + + Utils.getPlayerName(uuid) + + " is a potential alt of: " + + uuids.stream().map(Utils::getPlayerName).toList() + + "."; + } + + public static int getLastNotifId() { + return PlayerLogger.getInstance() + .getNotifRepository() + .getAll() + .mapToInt(Notif::getId) + .max() + .orElse(0); + } + + public static void purgeOldNotifs(int hours) { + LocalDateTime cutoff = LocalDateTime.now().minusHours(hours); + NotifRepository repository = PlayerLogger.getInstance().getNotifRepository(); + + Optional newestNotifOptional = repository.get(getLastNotifId()); + + if (newestNotifOptional.isEmpty()) { + return; + } + + Notif newestNotif = newestNotifOptional.get(); + + List toRemove = + repository + .getAll() + .filter(notif -> notif != newestNotif) + .filter(notif -> notif.getTime() != null && notif.getTime().isBefore(cutoff)) + .toList(); + + toRemove.forEach(repository::remove); + } +} diff --git a/src/main/java/com/xadale/playerlogger/PlayerLogger.java b/src/main/java/com/xadale/playerlogger/PlayerLogger.java index d99d0e8..88c7d4d 100644 --- a/src/main/java/com/xadale/playerlogger/PlayerLogger.java +++ b/src/main/java/com/xadale/playerlogger/PlayerLogger.java @@ -1,160 +1,115 @@ package com.xadale.playerlogger; -import club.minnced.discord.webhook.WebhookClient; -import java.io.BufferedReader; -import java.io.BufferedWriter; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.compat.FloodgateIntegration; +import com.xadale.playerlogger.config.Config; +import com.xadale.playerlogger.repositories.AuthorizedAccountsRepository; +import com.xadale.playerlogger.repositories.IpAssRepository; +import com.xadale.playerlogger.repositories.LastReadNotifRepository; +import com.xadale.playerlogger.repositories.NotifRepository; +import com.xadale.playerlogger.repositories.implementations.JsonAuthorizedAccountsRepositoryImpl; +import com.xadale.playerlogger.repositories.implementations.JsonIpAssRepositoryImpl; +import com.xadale.playerlogger.repositories.implementations.JsonLastReadNotifRepositoryImpl; +import com.xadale.playerlogger.repositories.implementations.JsonNotifRepositoryImpl; import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; import java.io.IOException; -import java.net.InetSocketAddress; import java.nio.file.Files; -import java.util.Objects; - -import club.minnced.discord.webhook.send.WebhookMessageBuilder; -import me.lucko.fabric.api.permissions.v0.Permissions; +import java.nio.file.Path; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; -import net.minecraft.text.Text; -import net.minecraft.util.Formatting; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.MinecraftServer; public class PlayerLogger implements ModInitializer { - private File logFile; - - private WebhookClient webhookClient; + public static String modId = "altx"; + private static PlayerLogger instance; + private MinecraftServer server; + private FloodgateIntegration floodgateIntegration; + private IpAssRepository ipAssDataRepository; + private NotifRepository notifRepository; + private LastReadNotifRepository lastReadNotifRepository; + private AuthorizedAccountsRepository authorizedAccountsRepository; + private Config config = Config.loadConfig(); @Override public void onInitialize() { - File logDir = new File("AltX-Files"); - if (!logDir.exists()) { - logDir.mkdir(); // Creates the folder if it doesn't exist + PlayerLogger.instance = this; + + ServerLifecycleEvents.SERVER_STARTING.register(s -> this.server = s); + this.floodgateIntegration = new FloodgateIntegration(); + + File ipAssFile = + PlayerLogger.getConfigFolder().resolve(PlayerLogger.modId + ".ips.json").toFile(); + File notifFile = + PlayerLogger.getConfigFolder().resolve(PlayerLogger.modId + ".notifs.json").toFile(); + File lastReadNotifFile = + PlayerLogger.getConfigFolder().resolve(PlayerLogger.modId + ".lastReadNotif.json").toFile(); + File authorizedAccountsFile = + PlayerLogger.getConfigFolder() + .resolve(PlayerLogger.modId + ".authorizedAccounts.json") + .toFile(); + + try { + Files.createDirectories(PlayerLogger.getConfigFolder()); + } catch (IOException ignored) { } - this.logFile = new File(logDir, "player_ips.txt"); - this.loadWebhook(logDir); - - // Register connection event to log player IPs - ServerPlayConnectionEvents.JOIN.register( - (handler, sender, server) -> { - String playerName = handler.getPlayer().getName().getString(); - - String ipAddress = "Unknown IP"; - if (handler.getConnectionAddress() instanceof InetSocketAddress inetSocketAddress) { - ipAddress = inetSocketAddress.getAddress().getHostAddress(); - } - - String logEntry = playerName + ";" + ipAddress; - - // Only proceed if the log entry is new - if (writeToFileIfAbsent(logEntry)) { - try (BufferedReader reader = new BufferedReader(new FileReader(logFile))) { - StringBuilder potentialAlts = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - String[] parts = line.split(";"); - if (parts.length == 2 - && parts[1].equals(ipAddress) - && !parts[0].equals(playerName)) { - if (potentialAlts.length() > 0) { - potentialAlts.append(", "); // Add a comma between names - } - potentialAlts.append(parts[0]); - } - } - - if (potentialAlts.length() > 0) { - // Found one or more potential alts - String ingameMessage = - "Player " + playerName + " is a potential alt of: " + potentialAlts + "."; - - String discordMessage = - "Player `" + playerName + "` is a potential alt of: `" + potentialAlts + "`"; - - // Send the message in discord - if (this.webhookClient != null) { - WebhookMessageBuilder builder = - new WebhookMessageBuilder() - .setContent(discordMessage) - .setUsername("AltX Notification") - .setAvatarUrl("https://raw.githubusercontent.com/OrdinarySMP/AltX/main/assets/AltX-avatar.png"); - - this.webhookClient.send(builder.build()); - } - - // Send the message to staff - server - .getPlayerManager() - .getPlayerList() - .forEach( - player -> { - if (Permissions.check(player, "altx.notify", 4)) { - player.sendMessage( - Text.literal(ingameMessage).formatted(Formatting.RED), false); - } - }); - } - } catch (IOException e) { - System.err.println("Failed to read log file: " + e.getMessage()); - } - } - }); - - Commands commands = new Commands(this); + + this.ipAssDataRepository = JsonIpAssRepositoryImpl.from(ipAssFile); + this.notifRepository = JsonNotifRepositoryImpl.from(notifFile); + this.lastReadNotifRepository = JsonLastReadNotifRepositoryImpl.from(lastReadNotifFile); + this.authorizedAccountsRepository = + JsonAuthorizedAccountsRepositoryImpl.from(authorizedAccountsFile); + + ServerPlayConnectionEvents.JOIN.register(NotificationHandler::onJoin); + + NotificationHandler.purgeOldNotifs(this.config.altNotifs.purgePeriod); + + Commands commands = new Commands(); commands.register(); } - private boolean writeToFileIfAbsent(String entry) { - try { - if (!logFile.exists()) { - logFile.createNewFile(); - } - - boolean alreadyLogged = false; - try (BufferedReader reader = new BufferedReader(new FileReader(logFile))) { - String line; - while ((line = reader.readLine()) != null) { - if (line.trim().equals(entry)) { - alreadyLogged = true; - break; - } - } - } - - if (!alreadyLogged) { - try (BufferedWriter writer = new BufferedWriter(new FileWriter(logFile, true))) { - writer.write(entry + "\n"); - } - return true; // New entry added - } - } catch (IOException e) { - System.err.println("Failed to write player log: " + e.getMessage()); - } - return false; // No new entry added or an exception occurred + public void reloadConfig() { + LogUtils.getLogger().info("Reloading Config"); + this.config = Config.loadConfig(); + WebhookMessageSender.reload(); + LogUtils.getLogger().info("Config succesfully reloaded"); } - public File getLogFile() { - return this.logFile; + public static Path getConfigFolder() { + return FabricLoader.getInstance().getConfigDir().resolve(PlayerLogger.modId); } - private void loadWebhook(File logDir) { - File webhookUrlFile = new File(logDir, "webhook-url.txt"); - String webhookUrl = ""; + public static PlayerLogger getInstance() { + return PlayerLogger.instance; + } - try { - if (!webhookUrlFile.exists()) { - webhookUrlFile.createNewFile(); - } - webhookUrl = Files.readString(webhookUrlFile.toPath()); - } catch (IOException e) { - System.out.println("Failed to load webhook url: " + e.getMessage()); - } + public MinecraftServer getServer() { + return this.server; + } - if (!Objects.equals(webhookUrl, "")) { - try { - this.webhookClient = WebhookClient.withUrl(webhookUrl); - } catch (IllegalArgumentException e) { - System.out.println("Failed to load webhook: " + e.getMessage()); - } - } + public FloodgateIntegration getFloodgateIntegration() { + return this.floodgateIntegration; + } + + public IpAssRepository getIpAssRepository() { + return this.ipAssDataRepository; + } + + public NotifRepository getNotifRepository() { + return this.notifRepository; + } + + public LastReadNotifRepository getLastReadNotifRepository() { + return this.lastReadNotifRepository; + } + + public AuthorizedAccountsRepository getAuthorizedAccountsRepository() { + return this.authorizedAccountsRepository; + } + + public Config getConfig() { + return this.config; } } diff --git a/src/main/java/com/xadale/playerlogger/Utils.java b/src/main/java/com/xadale/playerlogger/Utils.java new file mode 100644 index 0000000..6374526 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/Utils.java @@ -0,0 +1,95 @@ +package com.xadale.playerlogger; + +import com.google.gson.Gson; +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.yggdrasil.ProfileResult; +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.repositories.IpAssRepository; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; + +public class Utils { + + private static final Gson gson = new Gson(); + private static final Map uuidNameCache = new ConcurrentHashMap<>(); + + public static String getPlayerName(UUID uuid) { + if (PlayerLogger.getInstance().getFloodgateIntegration().isBedrock(uuid)) { + return PlayerLogger.getInstance().getFloodgateIntegration().getUsername(uuid); + } + + MinecraftServer server = PlayerLogger.getInstance().getServer(); + ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid); + if (player != null) { + String name = player.getGameProfile().getName(); + uuidNameCache.put(uuid, name); + return name; + } + + String cachedName = uuidNameCache.get(uuid); + if (cachedName != null) { + return cachedName; + } + + ProfileResult result = + PlayerLogger.getInstance().getServer().getSessionService().fetchProfile(uuid, false); + if (result == null) { + return "unknown"; + } + uuidNameCache.put(uuid, result.profile().getName()); + return result.profile().getName(); + } + + public static GameProfile fetchProfile(String name) { + if (PlayerLogger.getInstance().getFloodgateIntegration().isFloodGateName(name)) { + return PlayerLogger.getInstance().getFloodgateIntegration().getGameProfileFor(name); + } + try { + return fetchProfileData("https://api.mojang.com/users/profiles/minecraft/" + name); + } catch (IOException ignored) { + } + try { + return fetchProfileData("https://api.minetools.eu/uuid/" + name); + } catch (IOException e) { + return null; + } + } + + private static GameProfile fetchProfileData(String urlLink) throws IOException { + URL url = URI.create(urlLink).toURL(); + URLConnection connection = url.openConnection(); + connection.addRequestProperty("User-Agent", "DiscordJS"); + connection.addRequestProperty("Accept", "application/json"); + connection.connect(); + BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + String data = reader.lines().collect(Collectors.joining()); + if (data.endsWith("\"ERR\"}")) { + return null; + } + // fix uuid format + String fixed = + data.replaceFirst( + "\"(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)\"", + "$1-$2-$3-$4-$5"); + return gson.fromJson(fixed, GameProfile.class); + } + + public static Set getIpsOfUuid(IpAssRepository ipAssDataRepository, UUID uuid) { + return ipAssDataRepository + .getAll() + .filter(ipAss -> ipAss.getUuids().contains(uuid)) + .map(IpAss::getIp) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/com/xadale/playerlogger/WebhookMessageSender.java b/src/main/java/com/xadale/playerlogger/WebhookMessageSender.java new file mode 100644 index 0000000..ddce2c2 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/WebhookMessageSender.java @@ -0,0 +1,43 @@ +package com.xadale.playerlogger; + +import club.minnced.discord.webhook.WebhookClient; +import club.minnced.discord.webhook.send.WebhookMessage; +import club.minnced.discord.webhook.send.WebhookMessageBuilder; +import com.mojang.logging.LogUtils; + +public class WebhookMessageSender { + + private static WebhookClient webhookClient = null; + private static final String WEBHOOK_NAME = "AltX Notification"; + private static final String WEBHOOK_AVATAR_URL = + "https://raw.githubusercontent.com/OrdinarySMP/AltX/main/assets/AltX-avatar.png"; + + public static void reload() { + if (!PlayerLogger.getInstance().getConfig().altNotifs.enableWebhookNotifs) { + webhookClient = null; + return; + } + + String webhookUrl = PlayerLogger.getInstance().getConfig().altNotifs.webhookUrl; + try { + webhookClient = WebhookClient.withUrl(webhookUrl); + } catch (IllegalArgumentException e) { + webhookClient = null; + LogUtils.getLogger().warn("Failed to load webhook: " + e.getMessage()); + } + } + + public static void sendMessage(String messageString) { + if (webhookClient == null) { + return; + } + WebhookMessage message = + new WebhookMessageBuilder() + .setContent(messageString) + .setUsername(WEBHOOK_NAME) + .setAvatarUrl(WEBHOOK_AVATAR_URL) + .build(); + + webhookClient.send(message); + } +} diff --git a/src/main/java/com/xadale/playerlogger/commands/AuthorizeAltsCommand.java b/src/main/java/com/xadale/playerlogger/commands/AuthorizeAltsCommand.java new file mode 100644 index 0000000..5b238ab --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/commands/AuthorizeAltsCommand.java @@ -0,0 +1,67 @@ +package com.xadale.playerlogger.commands; + +import com.mojang.authlib.GameProfile; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.xadale.playerlogger.PlayerLogger; +import com.xadale.playerlogger.Utils; +import com.xadale.playerlogger.data.AuthorizedAccounts; +import java.util.List; +import java.util.stream.Collectors; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.text.Text; + +public class AuthorizeAltsCommand { + + public static int execute(CommandContext context) { + String playerString1 = StringArgumentType.getString(context, "player1").trim(); + String playerString2 = StringArgumentType.getString(context, "player2").trim(); + + GameProfile profile1 = Utils.fetchProfile(playerString1); + GameProfile profile2 = Utils.fetchProfile(playerString2); + + if (profile1 == null) { + context.getSource().sendFeedback(() -> Text.literal("§cPlayer 1 could not be found"), false); + } + if (profile2 == null) { + context.getSource().sendFeedback(() -> Text.literal("§cPlayer 2 could not be found"), false); + } + if (profile1 == null || profile2 == null) { + return 1; + } + + AuthorizedAccounts authorizedAccounts = + new AuthorizedAccounts(List.of(profile1.getId(), profile2.getId())); + PlayerLogger.getInstance().getAuthorizedAccountsRepository().add(authorizedAccounts); + + context + .getSource() + .sendFeedback(() -> Text.literal("Successfully added authorized accounts"), false); + + return 1; + } + + public static int listAuthorizedExecute(CommandContext context) { + List authorizedAccounts = + PlayerLogger.getInstance().getAuthorizedAccountsRepository().getAll().toList(); + + StringBuilder response = new StringBuilder(); + authorizedAccounts.forEach( + auth -> { + response.append("\n"); + response.append("§3- §f"); + String playernames = + auth.getUuids().stream().map(Utils::getPlayerName).collect(Collectors.joining(", ")); + response.append(playernames); + }); + + if (authorizedAccounts.isEmpty()) { + response.append("§cNo authorized accounts found."); + } else { + response.insert(0, "§bAuthorized accounts:"); + } + + context.getSource().sendFeedback(() -> Text.literal(response.toString()), false); + return 1; + } +} diff --git a/src/main/java/com/xadale/playerlogger/commands/HandleAltsCommand.java b/src/main/java/com/xadale/playerlogger/commands/HandleAltsCommand.java index 8c39f09..2c4212e 100644 --- a/src/main/java/com/xadale/playerlogger/commands/HandleAltsCommand.java +++ b/src/main/java/com/xadale/playerlogger/commands/HandleAltsCommand.java @@ -1,40 +1,33 @@ package com.xadale.playerlogger.commands; +import com.mojang.authlib.GameProfile; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; +import com.xadale.playerlogger.PlayerLogger; +import com.xadale.playerlogger.Utils; +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.repositories.IpAssRepository; +import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; - +import java.util.stream.Collectors; import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; public class HandleAltsCommand { private static final Pattern ipPattern = Pattern.compile("^(\\d{1,3}\\.){3}\\d{1,3}$"); - public static int execute(CommandContext context, File logFile) { + public static int execute( + CommandContext context, IpAssRepository ipAssDataRepository) { String query = StringArgumentType.getString(context, "query").trim(); - Map> ipToPlayers = new HashMap<>(); - Map> playerToIps = new HashMap<>(); final ServerCommandSource source = context.getSource(); - try { - HandleAltsCommand.readLogFile(ipToPlayers, playerToIps, logFile); - } catch (IOException e) { - context.getSource().sendError(Text.literal("§cFailed to read log file: " + e.getMessage())); - return 0; - } - if ((query.contains(".") && !Permissions.check(source, "altx.viewips", 4))) { context .getSource() @@ -47,8 +40,10 @@ public static int execute(CommandContext context, File logF Matcher m = ipPattern.matcher(query); if (m.matches()) { // Query is an IP address - Set players = ipToPlayers.get(query); - if (players != null) { + Optional ipAss = ipAssDataRepository.get(query); + if (ipAss.isPresent()) { + Set players = + ipAss.get().getUuids().stream().map(Utils::getPlayerName).collect(Collectors.toSet()); context .getSource() .sendFeedback( @@ -65,8 +60,23 @@ public static int execute(CommandContext context, File logF } // Query is a username - Set ips = playerToIps.get(query); - if (ips != null) { + ServerPlayerEntity player = + PlayerLogger.getInstance().getServer().getPlayerManager().getPlayer(query); + UUID uuid = null; + if (player == null) { + GameProfile profile = Utils.fetchProfile(query); + if (profile != null) { + uuid = profile.getId(); + } + } else { + uuid = player.getUuid(); + } + if (uuid == null) { + context.getSource().sendFeedback(() -> Text.literal("§cPlayer not found: " + query), false); + return 1; + } + Set ips = Utils.getIpsOfUuid(ipAssDataRepository, uuid); + if (!ips.isEmpty()) { StringBuilder response = new StringBuilder(); if (Permissions.check(source, "altx.viewips", 4)) { response @@ -78,7 +88,10 @@ public static int execute(CommandContext context, File logF response.append("§bPlayers with the same IP as §3").append(query).append("§b:"); } for (String ip : ips) { - Set players = ipToPlayers.get(ip); + Set players = + ipAssDataRepository.get(ip).get().getUuids().stream() + .map(Utils::getPlayerName) + .collect(Collectors.toSet()); response.append("\n"); if (Permissions.check(source, "altx.viewips", 4)) { @@ -96,25 +109,4 @@ public static int execute(CommandContext context, File logF } return 1; } - - private static void readLogFile( - Map> ipToPlayers, Map> playerToIps, File logFile) - throws IOException { - BufferedReader reader = new BufferedReader(new FileReader(logFile)); - - String line; - while ((line = reader.readLine()) != null) { - String[] parts = line.split(";"); - if (parts.length == 2) { - String playerName = parts[0].trim(); - String ipAddress = parts[1].trim(); - - ipToPlayers.computeIfAbsent(ipAddress, k -> new HashSet<>()).add(playerName); - - playerToIps.computeIfAbsent(playerName, k -> new HashSet<>()).add(ipAddress); - } - } - - reader.close(); - } } diff --git a/src/main/java/com/xadale/playerlogger/commands/ListIpsWithMultiplePlayers.java b/src/main/java/com/xadale/playerlogger/commands/ListIpsWithMultiplePlayers.java index 313e1f1..757fcc7 100644 --- a/src/main/java/com/xadale/playerlogger/commands/ListIpsWithMultiplePlayers.java +++ b/src/main/java/com/xadale/playerlogger/commands/ListIpsWithMultiplePlayers.java @@ -1,72 +1,91 @@ package com.xadale.playerlogger.commands; import com.mojang.brigadier.context.CommandContext; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.util.HashMap; +import com.xadale.playerlogger.PlayerLogger; +import com.xadale.playerlogger.Utils; +import com.xadale.playerlogger.compat.JustSyncIntegration; +import com.xadale.playerlogger.data.AuthorizedAccounts; +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.repositories.IpAssRepository; import java.util.HashSet; -import java.util.Map; +import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.Collectors; import me.lucko.fabric.api.permissions.v0.Permissions; import net.minecraft.server.command.ServerCommandSource; import net.minecraft.text.Text; public class ListIpsWithMultiplePlayers { - public static int execute(CommandContext context, File logFile) { - Map> ipToPlayers = new HashMap<>(); + public static int execute( + CommandContext context, IpAssRepository ipAssDataRepository) { - // check if show ips final ServerCommandSource source = context.getSource(); - try { - ListIpsWithMultiplePlayers.readLogFile(logFile, ipToPlayers); - } catch (IOException e) { - context.getSource().sendError(Text.literal("§cFailed to read log file: " + e.getMessage())); - return 0; - } + StringBuilder response = new StringBuilder(); - // Filter and build the result - StringBuilder response = new StringBuilder("§bIPs with two or more users:"); - boolean found = false; + CompletableFuture.runAsync( + () -> { + List multiAccountIps = + ipAssDataRepository + .getAll() + .filter(ipAss -> ipAss.getUuids().size() >= 2) + .filter(Predicate.not(ListIpsWithMultiplePlayers::isAuthorized)) + .toList(); - for (Map.Entry> entry : ipToPlayers.entrySet()) { - if (entry.getValue().size() >= 2) { - found = true; - response.append("\n"); - if (Permissions.check(source, "altx.viewips", 4)) { - response.append("§3- (§b").append(entry.getKey()).append("§3): §f"); - } else { - response.append("§3- §f"); - } - response.append(String.join(", ", entry.getValue())); - } - } + if (multiAccountIps.isEmpty()) { + response.append("§cNo IPs with two or more players found."); + } else { + multiAccountIps.forEach( + ipAss -> { + response.append("\n"); - if (!found) { - response.append("§cNo IPs with two or more players found."); - } + if (Permissions.check(source, "altx.viewips", 4)) { + response.append("§3- (§b").append(ipAss.getIp()).append("§3): §f"); + } else { + response.append("§3- §f"); + } - // Send the response - context.getSource().sendFeedback(() -> Text.literal(response.toString()), false); + String playerNames = + ipAss.getUuids().stream() + .map(Utils::getPlayerName) + .collect(Collectors.joining(", ")); + response.append(playerNames); + }); + } + if (!multiAccountIps.isEmpty()) { + response.insert(0, "§bIPs with two or more users:"); + } + + source.sendFeedback(() -> Text.literal(response.toString()), false); + }); return 1; } - private static void readLogFile(File logFile, Map> ipToPlayers) - throws IOException { - // Load the IP-to-players map from the log file - BufferedReader reader = new BufferedReader(new FileReader(logFile)); - String line; - while ((line = reader.readLine()) != null) { - String[] parts = line.split(";"); - if (parts.length == 2) { - String playerName = parts[0].trim(); - String ipAddress = parts[1].trim(); + private static boolean isAuthorized(IpAss ipass) { + Set uuidSet = new HashSet<>(); + for (UUID uuid : ipass.getUuids()) { + uuidSet.add(JustSyncIntegration.getIntegration().getPlayerLink(uuid).getUuid()); + } - ipToPlayers.computeIfAbsent(ipAddress, k -> new HashSet<>()).add(playerName); - } + // only registered alts from justsync + if (uuidSet.size() <= 1) { + return true; } + + Optional authorizedAccounts = + PlayerLogger.getInstance().getAuthorizedAccountsRepository().get(uuidSet.iterator().next()); + + // no authorized and multiple accounts on ip + if (authorizedAccounts.isEmpty()) { + return false; + } + + // authorized when all uuids are in authorization object + return new HashSet<>(authorizedAccounts.get().getUuids()).containsAll(uuidSet); } } diff --git a/src/main/java/com/xadale/playerlogger/compat/FloodgateIntegration.java b/src/main/java/com/xadale/playerlogger/compat/FloodgateIntegration.java new file mode 100644 index 0000000..6ec368a --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/compat/FloodgateIntegration.java @@ -0,0 +1,50 @@ +package com.xadale.playerlogger.compat; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import org.geysermc.floodgate.api.FloodgateApi; + +import com.mojang.authlib.GameProfile; + +import net.fabricmc.loader.api.FabricLoader; + +public class FloodgateIntegration { + private FloodgateApi floodgateApi; + + public FloodgateIntegration() { + if (FabricLoader.getInstance().isModLoaded("floodgate")) { + this.floodgateApi = FloodgateApi.getInstance(); + } + } + + public boolean isBedrock(UUID uuid) { + return this.floodgateApi != null && this.floodgateApi.isFloodgateId(uuid); + } + + public String getUsername(UUID uuid) { + if (this.floodgateApi != null) { + try { + return this.floodgateApi.getPlayerPrefix() + this.floodgateApi.getGamertagFor(uuid.getLeastSignificantBits()).get(); + } catch (InterruptedException | ExecutionException ignored) { + } + } + return "unknown"; + } + + public boolean isFloodGateName(String name) { + return this.floodgateApi != null && name.startsWith(this.floodgateApi.getPlayerPrefix()); + } + + public GameProfile getGameProfileFor(String name) { + String gamerTag = name.replaceFirst(this.floodgateApi.getPlayerPrefix(), ""); + try { + UUID id = this.floodgateApi.getUuidFor(gamerTag).get(); + String username = this.getUsername(id); + return new GameProfile(id, username); + } catch (InterruptedException | ExecutionException ignored) { + } + return null; + } + +} diff --git a/src/main/java/com/xadale/playerlogger/compat/JustSyncIntegration.java b/src/main/java/com/xadale/playerlogger/compat/JustSyncIntegration.java new file mode 100644 index 0000000..f873111 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/compat/JustSyncIntegration.java @@ -0,0 +1,67 @@ +package com.xadale.playerlogger.compat; + +import com.xadale.playerlogger.PlayerLogger; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import net.fabricmc.loader.api.FabricLoader; +import tronka.justsync.JustSyncApplication; +import tronka.justsync.linking.PlayerLink; + +/** Provides integration with Discord: JustSync mod for player UUID linking. */ +public class JustSyncIntegration { + private JustSyncApplication integration; + private static JustSyncIntegration instance; + private static boolean loaded = false; + + /** Initializes integration if Discord: JustSync is loaded and enabled in config. */ + private JustSyncIntegration() { + if (FabricLoader.getInstance().isModLoaded("discord-justsync") + && PlayerLogger.getInstance().getConfig().discordJustSyncIntegration.enable) { + if (JustSyncApplication.getInstance() != null) { + this.integration = JustSyncApplication.getInstance(); + } + } + JustSyncIntegration.instance = this; + } + + /** + * @return Singleton instance of this integration handler + */ + public static JustSyncIntegration getIntegration() { + if (!JustSyncIntegration.loaded) { + JustSyncIntegration.instance = new JustSyncIntegration(); + JustSyncIntegration.loaded = true; + } + return JustSyncIntegration.instance; + } + + /** + * @param uuid Player uuid + * @returns A stub of the PlayerLink object of justsync + */ + public PlayerLinkStub getPlayerLink(UUID uuid) { + if (this.integration != null && this.integration.getConfig().linking.enableLinking) { + Optional playerLink = this.integration.getLinkManager().getDataOf(uuid); + if (playerLink.isPresent()) { + return new PlayerLinkStub(playerLink.get()); + } + } + return new PlayerLinkStub(uuid); + } + + /** + * @param uuid Player UUID to check + * @return List of all related UUIDs (main + alts) from JustSync's linking system + */ + public List getRelatedUuids(UUID uuid) { + if (this.integration != null && this.integration.getConfig().linking.enableLinking) { + Optional playerLink = this.integration.getLinkManager().getDataOf(uuid); + if (playerLink.isPresent()) { + return playerLink.get().getAllUuids(); + } + } + + return List.of(uuid); + } +} diff --git a/src/main/java/com/xadale/playerlogger/compat/PlayerLinkStub.java b/src/main/java/com/xadale/playerlogger/compat/PlayerLinkStub.java new file mode 100644 index 0000000..9d84287 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/compat/PlayerLinkStub.java @@ -0,0 +1,37 @@ +package com.xadale.playerlogger.compat; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import tronka.justsync.linking.PlayerData; +import tronka.justsync.linking.PlayerLink; + +public class PlayerLinkStub { + + private final UUID uuid; + private final List alts; + + public PlayerLinkStub(PlayerLink playerLink) { + this.uuid = playerLink.getPlayerId(); + this.alts = playerLink.getAlts().stream().map(PlayerData::getId).collect(Collectors.toList()); + } + + public PlayerLinkStub(UUID uuid, List altsUuids) { + this.uuid = uuid; + this.alts = altsUuids; + } + + public PlayerLinkStub(UUID uuid) { + this.uuid = uuid; + this.alts = new ArrayList<>(); + } + + public UUID getUuid() { + return uuid; + } + + public List getAlts() { + return alts; + } +} diff --git a/src/main/java/com/xadale/playerlogger/config/Config.java b/src/main/java/com/xadale/playerlogger/config/Config.java new file mode 100644 index 0000000..81bdb49 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/config/Config.java @@ -0,0 +1,77 @@ +package com.xadale.playerlogger.config; + +import com.moandjiezana.toml.Toml; +import com.moandjiezana.toml.TomlComment; +import com.moandjiezana.toml.TomlWriter; +import com.xadale.playerlogger.PlayerLogger; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class Config { + + public static Config loadConfig() { + Path configDir = PlayerLogger.getConfigFolder(); + File configFile = configDir.resolve(PlayerLogger.modId + ".toml").toFile(); + Config instance; + if (configFile.exists()) { + instance = new Toml().read(configFile).to(Config.class); + } else { + instance = new Config(); + } + upgradeConfig(instance); + try { + Files.createDirectories(configDir); + new TomlWriter().write(instance, configFile); + } catch (IOException ignored) { + } + return instance; + } + + // config migration if needed + private static void upgradeConfig(Config instance) { + ; + } + + public AltNotifs altNotifs = new AltNotifs(); + @TomlComment({"------------------------------", + "", + "------------------------------"}) + public DiscordJustSyncIntegration discordJustSyncIntegration = new DiscordJustSyncIntegration(); + + public static class DiscordJustSyncIntegration { + @TomlComment({ + "check if account on same ip as other account is linked as alt", + "only relevant if alt notifs are enabled and Discord: JustSync is installed and the alt" + + " feature is in use" + }) + public boolean enable = false; + } + + public static class AltNotifs { + @TomlComment({ + "get a notification in chat when an account", + "joins with the same ip as another", + "players to notify are determined by 'altx.notify' permission or op level 4" + }) + public boolean enableNotifs = true; + + @TomlComment("send notifications to a webhook") + public boolean enableWebhookNotifs = false; + + @TomlComment("url of the webhook") + public String webhookUrl = ""; + + @TomlComment("show notifs issued when player was offline") + public boolean showMissedNotifsOnJoin = true; + + @TomlComment("oldest notification to show in hours") + public int notificationPeriod = 24; + + @TomlComment( + "period after which notifications will only be accessible through the respective file not" + + " ingame") + public int purgePeriod = 72; + } +} diff --git a/src/main/java/com/xadale/playerlogger/core/AbstractData.java b/src/main/java/com/xadale/playerlogger/core/AbstractData.java new file mode 100644 index 0000000..50f5201 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/core/AbstractData.java @@ -0,0 +1,23 @@ +package com.xadale.playerlogger.core; + +public abstract class AbstractData, ID> { + private transient DataRepository dataRepository; + + /** + * Returns the current Repository instance associated with this IpAss object. + * + * @return The Repository instance used for data operations + */ + public DataRepository getRepository() { + return this.dataRepository; + } + + /** + * Sets the Repository instance to be associated with this IpAss object. + * + * @param dataRepository The Repository instance to be used for data operations + */ + public void setRepository(DataRepository dataRepository) { + this.dataRepository = dataRepository; + } +} diff --git a/src/main/java/com/xadale/playerlogger/core/DataRepository.java b/src/main/java/com/xadale/playerlogger/core/DataRepository.java new file mode 100644 index 0000000..60edf03 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/core/DataRepository.java @@ -0,0 +1,17 @@ +package com.xadale.playerlogger.core; + +import java.util.Optional; +import java.util.stream.Stream; + +public interface DataRepository, ID> { + + Optional get(ID identifier); + + void add(T data); + + void remove(T data); + + void update(T data); + + Stream getAll(); +} diff --git a/src/main/java/com/xadale/playerlogger/core/LocalDateTimeAdapter.java b/src/main/java/com/xadale/playerlogger/core/LocalDateTimeAdapter.java new file mode 100644 index 0000000..110b42e --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/core/LocalDateTimeAdapter.java @@ -0,0 +1,31 @@ +package com.xadale.playerlogger.core; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import java.lang.reflect.Type; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class LocalDateTimeAdapter + implements JsonSerializer, JsonDeserializer { + + private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + @Override + public JsonElement serialize( + LocalDateTime src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.format(formatter)); + } + + @Override + public LocalDateTime deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return LocalDateTime.parse(json.getAsString(), formatter); + } +} diff --git a/src/main/java/com/xadale/playerlogger/data/AuthorizedAccounts.java b/src/main/java/com/xadale/playerlogger/data/AuthorizedAccounts.java new file mode 100644 index 0000000..4d7ab8f --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/data/AuthorizedAccounts.java @@ -0,0 +1,18 @@ +package com.xadale.playerlogger.data; + +import com.xadale.playerlogger.core.AbstractData; +import java.util.List; +import java.util.UUID; + +public class AuthorizedAccounts extends AbstractData { + + private List uuids; + + public AuthorizedAccounts(List uuids) { + this.uuids = uuids; + } + + public List getUuids() { + return uuids; + } +} diff --git a/src/main/java/com/xadale/playerlogger/data/IpAss.java b/src/main/java/com/xadale/playerlogger/data/IpAss.java new file mode 100644 index 0000000..1749303 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/data/IpAss.java @@ -0,0 +1,69 @@ +package com.xadale.playerlogger.data; + +import com.xadale.playerlogger.core.AbstractData; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Represents an IP address association with one or more UUIDs. + * + *

The IpAss (IP Association) class maintains a relationship between a single IP address and + * multiple unique identifiers (UUIDs), typically used to track associations between network + * addresses and entities. The class includes mechanisms to: + * + *

    + *
  • Store and manage UUIDs associated with an IP address + *
  • Prevent duplicate UUID entries + *
  • Sync changes through an associated Repository + *
+ */ +public class IpAss extends AbstractData { + + private String ip; + private List uuids; + + /** + * Constructs an IpAss object with the specified IP address and initial UUID. The UUID list is + * initialized with the provided UUID as its first entry. + * + * @param ip The IP address to associate with this object + * @param uuid The initial UUID to add to the association list + */ + public IpAss(String ip, UUID uuid) { + this.ip = ip; + this.uuids = new ArrayList<>(); + this.uuids.add(uuid); + } + + /** + * Returns the IP address associated with this IpAss object. + * + * @return The IP address string + */ + public String getIp() { + return ip; + } + + /** + * Returns a list of UUIDs associated with the IP address. + * + * @return A List containing all associated UUIDs + */ + public List getUuids() { + return uuids; + } + + /** + * Adds a UUID to the association list if it doesn't already exist, and triggers a data update + * through the associated Repository. + * + * @param uuid The UUID to add to the association list + */ + public void addUUid(UUID uuid) { + if (!this.uuids.contains(uuid)) { + this.uuids.add(uuid); + } + this.getRepository().update(this); + } +} diff --git a/src/main/java/com/xadale/playerlogger/data/LastReadNotif.java b/src/main/java/com/xadale/playerlogger/data/LastReadNotif.java new file mode 100644 index 0000000..85806be --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/data/LastReadNotif.java @@ -0,0 +1,30 @@ +package com.xadale.playerlogger.data; + +import com.xadale.playerlogger.core.AbstractData; +import java.util.UUID; + +public class LastReadNotif extends AbstractData { + + private UUID uuid; + private int lastNotifId; + + public LastReadNotif() {} + + public LastReadNotif(UUID uuid, int lastNotifId) { + this.uuid = uuid; + this.lastNotifId = lastNotifId; + } + + public UUID getUuid() { + return uuid; + } + + public int getLastNotifId() { + return lastNotifId; + } + + public void setLastNotifId(int lastNotifId) { + this.lastNotifId = lastNotifId; + this.getRepository().update(this); + } +} diff --git a/src/main/java/com/xadale/playerlogger/data/Notif.java b/src/main/java/com/xadale/playerlogger/data/Notif.java new file mode 100644 index 0000000..66a10c1 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/data/Notif.java @@ -0,0 +1,37 @@ +package com.xadale.playerlogger.data; + +import com.xadale.playerlogger.core.AbstractData; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public class Notif extends AbstractData { + + private int id; + private UUID uuid; + private List alts; + private LocalDateTime time; + + public Notif(int id, UUID uuid, List alts, LocalDateTime time) { + this.id = id; + this.uuid = uuid; + this.alts = alts; + this.time = time; + } + + public int getId() { + return id; + } + + public UUID getUuid() { + return uuid; + } + + public List getAlts() { + return alts; + } + + public LocalDateTime getTime() { + return time; + } +} diff --git a/src/main/java/com/xadale/playerlogger/mixin/PlayerManagerMixin.java b/src/main/java/com/xadale/playerlogger/mixin/PlayerManagerMixin.java new file mode 100644 index 0000000..37ed59a --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/mixin/PlayerManagerMixin.java @@ -0,0 +1,41 @@ +package com.xadale.playerlogger.mixin; + +import com.mojang.authlib.GameProfile; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.IpLogger; +import java.net.InetSocketAddress; +import java.util.UUID; +import net.minecraft.network.ClientConnection; +import net.minecraft.server.network.ServerLoginNetworkHandler; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(value = ServerLoginNetworkHandler.class, priority = 100) +public class PlayerManagerMixin { + + @Final @Shadow ClientConnection connection; + + @Inject(method = "tickVerify", at = @At("HEAD")) + private void canJoin(GameProfile profile, CallbackInfo ci) { + UUID uuid = profile.getId(); + String ip = null; + try { + if (this.connection.getAddress() instanceof InetSocketAddress inetAddr) { + ip = inetAddr.getAddress().getHostAddress(); + } + } catch (Exception e) { + LogUtils.getLogger().info("ip gathering not successful"); + return; + } + + if (ip == null) { + return; + } + + IpLogger.handleJoin(ip, uuid); + } +} diff --git a/src/main/java/com/xadale/playerlogger/repositories/AuthorizedAccountsRepository.java b/src/main/java/com/xadale/playerlogger/repositories/AuthorizedAccountsRepository.java new file mode 100644 index 0000000..9cbc4b0 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/AuthorizedAccountsRepository.java @@ -0,0 +1,7 @@ +package com.xadale.playerlogger.repositories; + +import com.xadale.playerlogger.core.DataRepository; +import com.xadale.playerlogger.data.AuthorizedAccounts; +import java.util.UUID; + +public interface AuthorizedAccountsRepository extends DataRepository {} diff --git a/src/main/java/com/xadale/playerlogger/repositories/IpAssRepository.java b/src/main/java/com/xadale/playerlogger/repositories/IpAssRepository.java new file mode 100644 index 0000000..0a81dc1 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/IpAssRepository.java @@ -0,0 +1,6 @@ +package com.xadale.playerlogger.repositories; + +import com.xadale.playerlogger.core.DataRepository; +import com.xadale.playerlogger.data.IpAss; + +public interface IpAssRepository extends DataRepository {} diff --git a/src/main/java/com/xadale/playerlogger/repositories/LastReadNotifRepository.java b/src/main/java/com/xadale/playerlogger/repositories/LastReadNotifRepository.java new file mode 100644 index 0000000..8b1e492 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/LastReadNotifRepository.java @@ -0,0 +1,7 @@ +package com.xadale.playerlogger.repositories; + +import com.xadale.playerlogger.core.DataRepository; +import com.xadale.playerlogger.data.LastReadNotif; +import java.util.UUID; + +public interface LastReadNotifRepository extends DataRepository {} diff --git a/src/main/java/com/xadale/playerlogger/repositories/NotifRepository.java b/src/main/java/com/xadale/playerlogger/repositories/NotifRepository.java new file mode 100644 index 0000000..c2a6eaa --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/NotifRepository.java @@ -0,0 +1,6 @@ +package com.xadale.playerlogger.repositories; + +import com.xadale.playerlogger.core.DataRepository; +import com.xadale.playerlogger.data.Notif; + +public interface NotifRepository extends DataRepository {} diff --git a/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonAuthorizedAccountsRepositoryImpl.java b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonAuthorizedAccountsRepositoryImpl.java new file mode 100644 index 0000000..3d34855 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonAuthorizedAccountsRepositoryImpl.java @@ -0,0 +1,116 @@ +package com.xadale.playerlogger.repositories.implementations; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.data.AuthorizedAccounts; +import com.xadale.playerlogger.repositories.AuthorizedAccountsRepository; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import org.slf4j.Logger; + +public class JsonAuthorizedAccountsRepositoryImpl implements AuthorizedAccountsRepository { + + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final Logger LOGGER = LogUtils.getLogger(); + private final File file; + private List data; + + private JsonAuthorizedAccountsRepositoryImpl(File file) { + this.file = file; + if (!file.exists()) { + this.data = new ArrayList<>(); + return; + } + try (FileReader reader = new FileReader(file)) { + this.data = gson.fromJson(reader, new TypeToken>() {}); + } catch (JsonSyntaxException ex) { + throw new RuntimeException("Cannot parse data in %s".formatted(file.getAbsolutePath()), ex); + } catch (Exception e) { + throw new RuntimeException("Cannot load data (%s)".formatted(file.getAbsolutePath()), e); + } + if (this.data == null) { + // gson failed to parse a valid list + this.data = new ArrayList<>(); + } + this.data.forEach(data -> data.setRepository(this)); + System.out.println("Loaded " + this.data.size() + " data entries"); + } + + public static AuthorizedAccountsRepository from(File file) { + return new JsonAuthorizedAccountsRepositoryImpl(file); + } + + @Override + public Optional get(UUID uuid) { + return this.data.stream().filter(authAlts -> authAlts.getUuids().contains(uuid)).findFirst(); + } + + @Override + public void add(AuthorizedAccounts authorizedAccounts) { + + List authorizedAccountsList = + authorizedAccounts.getUuids().stream() + .map(this::get) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + + // none authorized already + if (authorizedAccountsList.isEmpty()) { + this.data.add(authorizedAccounts); + authorizedAccounts.setRepository(this); + this.onUpdated(); + return; + } + + // all authorized with each other already + if (new HashSet<>(authorizedAccountsList.getFirst().getUuids()) + .containsAll(authorizedAccounts.getUuids())) { + return; + } + + Set uuidSet = new HashSet<>(authorizedAccounts.getUuids()); + for (AuthorizedAccounts authAlts : authorizedAccountsList) { + uuidSet.addAll(authAlts.getUuids()); + this.remove(authAlts); + } + AuthorizedAccounts newAuthorizedAccounts = new AuthorizedAccounts(new ArrayList<>(uuidSet)); + this.add(newAuthorizedAccounts); + } + + @Override + public void remove(AuthorizedAccounts authorizedAccounts) { + this.data.remove(authorizedAccounts); + this.onUpdated(); + } + + @Override + public void update(AuthorizedAccounts authorizedAccounts) { + this.onUpdated(); + } + + private void onUpdated() { + try (FileWriter writer = new FileWriter(this.file)) { + gson.toJson(this.data, writer); + } catch (IOException e) { + LOGGER.error("Failed to save data to file {}", this.file.getAbsolutePath(), e); + } + } + + @Override + public Stream getAll() { + return this.data.stream(); + } +} diff --git a/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonIpAssRepositoryImpl.java b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonIpAssRepositoryImpl.java new file mode 100644 index 0000000..a03fc06 --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonIpAssRepositoryImpl.java @@ -0,0 +1,87 @@ +package com.xadale.playerlogger.repositories.implementations; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.data.IpAss; +import com.xadale.playerlogger.repositories.IpAssRepository; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.slf4j.Logger; + +public class JsonIpAssRepositoryImpl implements IpAssRepository { + + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final Logger LOGGER = LogUtils.getLogger(); + private final File file; + private List data; + + private JsonIpAssRepositoryImpl(File file) { + this.file = file; + if (!file.exists()) { + this.data = new ArrayList<>(); + return; + } + try (FileReader reader = new FileReader(file)) { + this.data = gson.fromJson(reader, new TypeToken>() {}); + } catch (JsonSyntaxException ex) { + throw new RuntimeException("Cannot parse data in %s".formatted(file.getAbsolutePath()), ex); + } catch (Exception e) { + throw new RuntimeException("Cannot load data (%s)".formatted(file.getAbsolutePath()), e); + } + if (this.data == null) { + // gson failed to parse a valid list + this.data = new ArrayList<>(); + } + this.data.forEach(data -> data.setRepository(this)); + System.out.println("Loaded " + this.data.size() + " data entries"); + } + + public static IpAssRepository from(File file) { + return new JsonIpAssRepositoryImpl(file); + } + + @Override + public Optional get(String ip) { + return this.data.stream().filter(link -> ip.equals(link.getIp())).findFirst(); + } + + @Override + public void add(IpAss ipAss) { + this.data.add(ipAss); + ipAss.setRepository(this); + this.onUpdated(); + } + + @Override + public void remove(IpAss ipAss) { + this.data.remove(ipAss); + this.onUpdated(); + } + + @Override + public void update(IpAss ipAss) { + this.onUpdated(); + } + + private void onUpdated() { + try (FileWriter writer = new FileWriter(this.file)) { + gson.toJson(this.data, writer); + } catch (IOException e) { + LOGGER.error("Failed to save data to file {}", this.file.getAbsolutePath(), e); + } + } + + @Override + public Stream getAll() { + return this.data.stream(); + } +} diff --git a/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonLastReadNotifRepositoryImpl.java b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonLastReadNotifRepositoryImpl.java new file mode 100644 index 0000000..22d245c --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonLastReadNotifRepositoryImpl.java @@ -0,0 +1,88 @@ +package com.xadale.playerlogger.repositories.implementations; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.data.LastReadNotif; +import com.xadale.playerlogger.repositories.LastReadNotifRepository; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import org.slf4j.Logger; + +public class JsonLastReadNotifRepositoryImpl implements LastReadNotifRepository { + + private static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private static final Logger LOGGER = LogUtils.getLogger(); + private final File file; + private List data; + + private JsonLastReadNotifRepositoryImpl(File file) { + this.file = file; + if (!file.exists()) { + this.data = new ArrayList<>(); + return; + } + try (FileReader reader = new FileReader(file)) { + this.data = gson.fromJson(reader, new TypeToken>() {}); + } catch (JsonSyntaxException ex) { + throw new RuntimeException("Cannot parse data in %s".formatted(file.getAbsolutePath()), ex); + } catch (Exception e) { + throw new RuntimeException("Cannot load data (%s)".formatted(file.getAbsolutePath()), e); + } + if (this.data == null) { + // gson failed to parse a valid list + this.data = new ArrayList<>(); + } + this.data.forEach(data -> data.setRepository(this)); + System.out.println("Loaded " + this.data.size() + " last read notification entries"); + } + + public static LastReadNotifRepository from(File file) { + return new JsonLastReadNotifRepositoryImpl(file); + } + + @Override + public Optional get(UUID uuid) { + return this.data.stream().filter(notif -> uuid.equals(notif.getUuid())).findFirst(); + } + + @Override + public void add(LastReadNotif notif) { + this.data.add(notif); + notif.setRepository(this); + this.onUpdated(); + } + + @Override + public void remove(LastReadNotif notif) { + this.data.remove(notif); + this.onUpdated(); + } + + @Override + public void update(LastReadNotif notif) { + this.onUpdated(); + } + + private void onUpdated() { + try (FileWriter writer = new FileWriter(this.file)) { + gson.toJson(this.data, writer); + } catch (IOException e) { + LOGGER.error("Failed to save data to file {}", this.file.getAbsolutePath(), e); + } + } + + @Override + public Stream getAll() { + return this.data.stream(); + } +} diff --git a/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonNotifRepositoryImpl.java b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonNotifRepositoryImpl.java new file mode 100644 index 0000000..82d6c5d --- /dev/null +++ b/src/main/java/com/xadale/playerlogger/repositories/implementations/JsonNotifRepositoryImpl.java @@ -0,0 +1,93 @@ +package com.xadale.playerlogger.repositories.implementations; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import com.mojang.logging.LogUtils; +import com.xadale.playerlogger.core.LocalDateTimeAdapter; +import com.xadale.playerlogger.data.Notif; +import com.xadale.playerlogger.repositories.NotifRepository; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.slf4j.Logger; + +public class JsonNotifRepositoryImpl implements NotifRepository { + + private static final Gson gson = + new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter()) + .create(); + private static final Logger LOGGER = LogUtils.getLogger(); + private final File file; + private List data; + + private JsonNotifRepositoryImpl(File file) { + this.file = file; + if (!file.exists()) { + this.data = new ArrayList<>(); + return; + } + try (FileReader reader = new FileReader(file)) { + this.data = gson.fromJson(reader, new TypeToken>() {}); + } catch (JsonSyntaxException ex) { + throw new RuntimeException("Cannot parse data in %s".formatted(file.getAbsolutePath()), ex); + } catch (Exception e) { + throw new RuntimeException("Cannot load data (%s)".formatted(file.getAbsolutePath()), e); + } + if (this.data == null) { + // gson failed to parse a valid list + this.data = new ArrayList<>(); + } + this.data.forEach(data -> data.setRepository(this)); + System.out.println("Loaded " + this.data.size() + " notification entries"); + } + + public static NotifRepository from(File file) { + return new JsonNotifRepositoryImpl(file); + } + + @Override + public Optional get(Integer id) { + return this.data.stream().filter(notif -> id.equals(notif.getId())).findFirst(); + } + + @Override + public void add(Notif notif) { + this.data.add(notif); + notif.setRepository(this); + this.onUpdated(); + } + + @Override + public void remove(Notif notif) { + this.data.remove(notif); + this.onUpdated(); + } + + @Override + public void update(Notif notif) { + this.onUpdated(); + } + + private void onUpdated() { + try (FileWriter writer = new FileWriter(this.file)) { + gson.toJson(this.data, writer); + } catch (IOException e) { + LOGGER.error("Failed to save data to file {}", this.file.getAbsolutePath(), e); + } + } + + @Override + public Stream getAll() { + return this.data.stream(); + } +} diff --git a/src/main/resources/altx.mixins.json b/src/main/resources/altx.mixins.json new file mode 100644 index 0000000..93e110f --- /dev/null +++ b/src/main/resources/altx.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.xadale.playerlogger.mixin", + "compatibilityLevel": "JAVA_21", + "server": [ + "PlayerManagerMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index f23fe44..0ea00dc 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,31 +1,35 @@ { - "schemaVersion": 1, - "id": "altx", - "version": "${version}", - "name": "AltX", - "description": "A lightweight Fabric mod that logs player IPs and detects alt accounts.", - "authors": [ - "MrCookiePrincess" - ], - "contact": { - "homepage": "https://github.com/OrdinarySMP", - "sources": "https://github.com/OrdinarySMP/AltX-fabric" - }, - "license": "GPL-3.0-or-later", - "icon": "assets/modid/icon.png", - "environment": "*", - "entrypoints": { - "main": [ - "com.xadale.playerlogger.PlayerLogger" - ] - }, - "depends": { - "fabricloader": ">=0.16.9", - "minecraft": "~1.21.4", - "java": ">=21", - "fabric-api": "*" - }, - "suggests": { - "another-mod": "*" - } + "schemaVersion": 1, + "id": "altx", + "version": "${version}", + "name": "AltX", + "description": "A lightweight Fabric mod that logs player IPs and detects alt accounts.", + "authors": [ + "MrCookiePrincess" + ], + "contributors": [ + "PadBro", + "GoldenKoopa" + ], + "contact": { + "homepage": "https://github.com/OrdinarySMP", + "sources": "https://github.com/OrdinarySMP/AltX" + }, + "license": "GPL-3.0-or-later", + "icon": "assets/modid/icon.png", + "environment": "server", + "entrypoints": { + "main": [ + "com.xadale.playerlogger.PlayerLogger" + ] + }, + "mixins": [ + "altx.mixins.json" + ], + "depends": { + "fabricloader": ">=${loader_version}", + "minecraft": "~${minecraft_version}", + "java": ">=21", + "fabric-api": "*" + } }