diff --git a/buildsystem-core/build.gradle.kts b/buildsystem-core/build.gradle.kts index 3b3de5ca..fa040aa0 100644 --- a/buildsystem-core/build.gradle.kts +++ b/buildsystem-core/build.gradle.kts @@ -249,6 +249,10 @@ bukkit { description = "Permission for creating world types." default = BukkitPluginDescription.Permission.Default.TRUE } + register("buildsystem.download") { + description = "Download a world as a single-player save." + default = BukkitPluginDescription.Permission.Default.OP + } register("buildsystem.create.category") { description = "Create a world in a navigator category. Category ids are dynamic; grant buildsystem.create.category. to allow a specific category." diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java index 6e692908..feb45be2 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java @@ -89,6 +89,8 @@ public void onEnable() { services.settings().displayScoreboard(pl); }); + services.worldDownload().start(); + new BuildSystemMetrics(this, services.config(), services.player()).register(); this.configSaveTask = Bukkit.getScheduler() @@ -114,6 +116,7 @@ public void onDisable() { }); services.navigatorEditor().restoreAll(); + services.worldDownload().stop(); services.backup().close(); services.world().cancelAllUnloadTasks(); @@ -218,6 +221,7 @@ public void reloadConfigData(boolean init) { services.config().load(); if (isEnabled()) { services.backup().reload(); + services.worldDownload().reload(); } if (init) { diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/Services.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/Services.java index 63048643..ba9f37f7 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/Services.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/Services.java @@ -37,6 +37,7 @@ import de.eintosti.buildsystem.world.data.WorldStatusRegistryImpl; import de.eintosti.buildsystem.world.display.CustomizableIcons; import de.eintosti.buildsystem.world.display.NavigatorCategoryRegistryImpl; +import de.eintosti.buildsystem.world.download.WorldDownloadService; import de.eintosti.buildsystem.world.spawn.SpawnService; import org.bukkit.NamespacedKey; import org.jspecify.annotations.NullMarked; @@ -66,6 +67,7 @@ public final class Services { private @Nullable SpawnService spawnService; private @Nullable WorldServiceImpl worldService; private @Nullable BackupServiceImpl backupService; + private @Nullable WorldDownloadService worldDownloadService; private @Nullable CustomizableIcons customizableIcons; private @Nullable NavigatorCategoryRegistryImpl navigatorCategoryRegistry; private @Nullable WorldStatusRegistryImpl worldStatusRegistry; @@ -122,6 +124,8 @@ void initClasses() { this.noClipService = new NoClipService(plugin); this.worldService = new WorldServiceImpl(plugin, this); this.backupService = new BackupServiceImpl(plugin, config(), messages(), world(), this::spawn); + this.worldDownloadService = + new WorldDownloadService(config(), taskScheduler, plugin.getLogger(), plugin.getDataFolder()); this.settingsService = new SettingsService(plugin, config(), messages(), player(), world()); this.spawnService = new SpawnService(plugin, world(), taskScheduler); this.menuItems = new MenuItems(plugin, messages(), settings()); @@ -194,6 +198,10 @@ public BackupServiceImpl backup() { return checkNotNull(backupService, "BackupServiceImpl"); } + public WorldDownloadService worldDownload() { + return checkNotNull(worldDownloadService, "WorldDownloadService"); + } + public CustomizableIcons customizableIcons() { return checkNotNull(customizableIcons, "CustomizableIcons"); } diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java index 0bf5123f..387569bb 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java @@ -33,6 +33,7 @@ import de.eintosti.buildsystem.world.WorldServiceImpl; import de.eintosti.buildsystem.world.backup.BackupServiceImpl; import de.eintosti.buildsystem.world.display.NavigatorCategoryRegistryImpl; +import de.eintosti.buildsystem.world.download.WorldDownloadService; import java.io.File; import java.util.List; import java.util.logging.Logger; @@ -61,6 +62,7 @@ public WorldsCommand(BuildSystemPlugin plugin, Services services) { PlayerLookupService playerLookupService = services.playerLookup(); NavigatorCategoryRegistryImpl navigatorCategoryRegistry = services.navigatorCategoryRegistry(); BackupServiceImpl backupService = services.backup(); + WorldDownloadService downloadService = services.worldDownload(); Logger logger = plugin.getLogger(); File dataFolder = plugin.getDataFolder(); TaskScheduler scheduler = services.scheduler(); @@ -73,6 +75,7 @@ public WorldsCommand(BuildSystemPlugin plugin, Services services) { new BackupsSubCommand(messages, worldService, backupService, menus), new BuildersSubCommand(messages, worldService, menus), new DeleteSubCommand(messages, worldService, configService, menus), + new DownloadSubCommand(messages, worldService, downloadService, scheduler, logger), new EditSubCommand(messages, worldService, menus), new FolderSubCommand(messages, worldService, navigatorCategoryRegistry, prompts), new HelpSubCommand(messages, logger), diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DownloadSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DownloadSubCommand.java new file mode 100644 index 00000000..5e9c10db --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DownloadSubCommand.java @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.AbstractSubCommand; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.i18n.Messages; +import de.eintosti.buildsystem.i18n.Placeholders; +import de.eintosti.buildsystem.util.TaskScheduler; +import de.eintosti.buildsystem.util.WorldFlush; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.download.ExportProgressBar; +import de.eintosti.buildsystem.world.download.WorldDownloadService; +import de.eintosti.buildsystem.world.download.WorldExporter; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import net.md_5.bungee.api.ChatMessageType; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class DownloadSubCommand extends AbstractSubCommand { + + /** + * How often the action bar is redrawn. Four frames a second reads as motion without spamming packets, and the + * action bar itself fades after about three seconds, so it must be refreshed well inside that. + */ + private static final long ANIMATION_PERIOD_TICKS = 5L; + + /** + * Where the clickable button goes in the finished message, quoted because {@link String#split} takes a regex. + */ + private static final String BUTTON_PLACEHOLDER = Pattern.quote("%button%"); + + private final WorldDownloadService downloadService; + private final TaskScheduler scheduler; + private final Logger logger; + + /** + * Players with an export in flight. Zipping a world is expensive, so a player cannot stack up exports by spamming + * the command. + */ + private final Set preparing = ConcurrentHashMap.newKeySet(); + + public DownloadSubCommand( + Messages messages, + WorldServiceImpl worldService, + WorldDownloadService downloadService, + TaskScheduler scheduler, + Logger logger) { + super(messages, worldService); + this.downloadService = downloadService; + this.scheduler = scheduler; + this.logger = logger; + } + + @Override + public void execute(Player player, String worldName, String[] args) { + BuildWorld buildWorld = requireWorld(player, worldName, args, 2, "worlds_download"); + if (buildWorld == null) { + return; + } + + if (!downloadService.isEnabled()) { + messages.sendMessage(player, "worlds_download_disabled"); + return; + } + + UUID playerId = player.getUniqueId(); + if (!preparing.add(playerId)) { + messages.sendMessage(player, "worlds_download_in_progress"); + return; + } + + Placeholders worldPlaceholder = Placeholders.of("%world%", buildWorld.getName()); + messages.sendMessage(player, "worlds_download_preparing", worldPlaceholder); + buildWorld.getWorld().ifPresent(WorldFlush::saveAndFlush); + + AtomicLong packedBytes = new AtomicLong(); + AtomicLong totalBytes = new AtomicLong(); + BukkitTask animation = startProgressAnimation(player, buildWorld, packedBytes, totalBytes); + + downloadService + .prepare(buildWorld, (packed, total) -> { + packedBytes.set(packed); + totalBytes.set(total); + }) + .whenCompleteAsync( + (url, throwable) -> { + preparing.remove(playerId); + animation.cancel(); + clearActionBar(player); + if (throwable != null) { + sendFailure(player, buildWorld, worldPlaceholder, throwable); + return; + } + if (player.isOnline()) { + sendLink(player, buildWorld, url); + } + }, + scheduler.mainThread()); + } + + /** + * Drives the action bar while the export runs. The frame counter advances every tick of this task, so the bar + * keeps moving even while a single large region file is being packed. + */ + private BukkitTask startProgressAnimation( + Player player, BuildWorld buildWorld, AtomicLong packedBytes, AtomicLong totalBytes) { + AtomicInteger frame = new AtomicInteger(); + return scheduler.runTimer( + () -> { + if (!player.isOnline()) { + return; + } + long total = totalBytes.get(); + double fraction = total <= 0 ? 0 : (double) packedBytes.get() / total; + int currentFrame = frame.getAndIncrement(); + + sendActionBar( + player, + messages.getString( + "worlds_download_progress", + player, + Placeholders.of() + .add("%world%", buildWorld.getName()) + .add("%bar%", ExportProgressBar.bar(fraction, currentFrame)) + .add("%percent%", ExportProgressBar.percentText(fraction)) + .add("%spinner%", ExportProgressBar.spinner(currentFrame)) + .build())); + }, + 0L, + ANIMATION_PERIOD_TICKS); + } + + private static void sendActionBar(Player player, String message) { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(message)); + } + + private static void clearActionBar(Player player) { + if (player.isOnline()) { + sendActionBar(player, ""); + } + } + + /** + * Reports the failure in the player's terms. A world that outgrows its limit or a full storage budget is an + * operator-tunable condition rather than a bug, so neither is logged as one. + */ + private void sendFailure(Player player, BuildWorld buildWorld, Placeholders worldPlaceholder, Throwable throwable) { + Throwable cause = throwable instanceof CompletionException && throwable.getCause() != null + ? throwable.getCause() + : throwable; + if (cause instanceof UncheckedIOException uncheckedIoException) { + cause = uncheckedIoException.getCause(); + } + + switch (cause) { + case WorldExporter.WorldTooLargeException ignored -> + messages.sendMessage(player, "worlds_download_too_large", worldPlaceholder); + case WorldDownloadService.StorageFullException ignored -> + messages.sendMessage(player, "worlds_download_storage_full", worldPlaceholder); + default -> { + logger.log(Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable); + messages.sendMessage(player, "worlds_download_failed", worldPlaceholder); + } + } + } + + /** + * Sends the finished message with only its {@code %button%} segment carrying the link, so the rest of the line + * cannot be clicked by accident. + * + *

A messages.yml written before the button existed has no {@code %button%} in it — an upgrade keeps the old + * line, since only missing keys are filled in. Such a message becomes clickable as a whole, the way it used to be: + * appending a button instead would print the words twice. + */ + private void sendLink(Player player, BuildWorld buildWorld, String url) { + Placeholders placeholders = Placeholders.of() + .add("%world%", buildWorld.getName()) + .add("%minutes%", downloadService.getExpirationMinutes()) + .build(); + String text = messages.getString("worlds_download_finished", player, placeholders); + + String[] parts = text.split(BUTTON_PLACEHOLDER, 2); + if (parts.length == 1) { + player.spigot().sendMessage(linked(TextComponent.fromLegacyText(text), url)); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + return; + } + + TextComponent message = new TextComponent(); + message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[0]))); + message.addExtra(linked( + TextComponent.fromLegacyText(messages.getString("worlds_download_button", player, placeholders)), url)); + message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[1]))); + + player.spigot().sendMessage(message); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + } + + private static TextComponent linked(BaseComponent[] text, String url) { + TextComponent component = new TextComponent(text); + component.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); + component.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url))); + return component; + } + + @Override + public List complete(Player player, String[] args) { + if (args.length != 2) { + return List.of(); + } + + WorldStorage worldStorage = worldService.getWorldStorage(); + return WorldsCompletions.permittedWorldNames( + player, worldStorage, getArgument().getPermission(), args[1]); + } + + @Override + public Argument getArgument() { + return WorldsArgument.DOWNLOAD; + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java index 11ca4741..a54e8594 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java @@ -131,6 +131,12 @@ protected List getCommands(Player player) { Permissions.REMOVESPAWN), createComponent( player, "/worlds delete ", "worlds_help_delete", "/worlds delete ", Permissions.DELETE), + createComponent( + player, + "/worlds download ", + "worlds_help_download", + "/worlds download ", + Permissions.DOWNLOAD), createComponent( player, "/worlds import ", "worlds_help_import", "/worlds import ", Permissions.IMPORT), createComponent( diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java index d3206b59..e45534ed 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java @@ -17,9 +17,10 @@ */ package de.eintosti.buildsystem.command.subcommand.worlds; +import com.cryptomorin.xseries.profiles.exceptions.ProfileException; +import com.cryptomorin.xseries.profiles.objects.Profileable; import de.eintosti.buildsystem.api.storage.WorldStorage; import de.eintosti.buildsystem.api.world.BuildWorld; -import de.eintosti.buildsystem.api.world.data.WorldDataKey; import de.eintosti.buildsystem.command.subcommand.AbstractSubCommand; import de.eintosti.buildsystem.command.subcommand.Argument; import de.eintosti.buildsystem.i18n.Messages; @@ -29,7 +30,9 @@ import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; @NullMarked public class SetItemSubCommand extends AbstractSubCommand { @@ -51,10 +54,26 @@ public void execute(Player player, String worldName, String[] args) { return; } - buildWorld.getData().set(WorldDataKey.MATERIAL, itemStack.getType()); + buildWorld.setIcon(itemStack.getType()); + if (itemStack.getItemMeta() instanceof SkullMeta) { + buildWorld.setIconSkullTexture(skullTexture(itemStack)); + } messages.sendMessage(player, "worlds_setitem_set", Placeholders.of("%world%", buildWorld.getName())); } + /** + * Reads the texture off a held head, or {@code null} for a blank one (and for a head whose profile cannot be + * resolved without a Mojang lookup, which must not happen on the main thread). + */ + private @Nullable String skullTexture(ItemStack itemStack) { + try { + Profileable profileable = Profileable.of(itemStack); + return profileable.isReady() ? profileable.getProfileValue() : null; + } catch (ProfileException e) { + return null; + } + } + @Override public List complete(Player player, String[] args) { if (args.length != 2) { diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/WorldsArgument.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/WorldsArgument.java index 0b75bd6a..21633fbc 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/WorldsArgument.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/WorldsArgument.java @@ -29,6 +29,7 @@ public enum WorldsArgument implements Argument { BACKUP("backup", Permissions.BACKUP), BUILDERS("builders", Permissions.BUILDERS), DELETE("delete", Permissions.DELETE), + DOWNLOAD("download", Permissions.DOWNLOAD), EDIT("edit", Permissions.EDIT), FOLDER("folder", Permissions.FOLDER), HELP("help", Permissions.HELP_WORLDS), diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/ConfigService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/ConfigService.java index 65514a4c..b3b3517b 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/ConfigService.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/ConfigService.java @@ -198,6 +198,16 @@ private static PluginConfig.World parseWorld(FileConfiguration config, Logger lo StorageSettingsFactory.fromConfig(config, logger), autoBackup); + int downloadPort = config.getInt("world.download.port", 8080); + PluginConfig.World.Download download = new PluginConfig.World.Download( + config.getBoolean("world.download.enabled", false), + downloadPort, + Objects.requireNonNullElse(config.getString("world.download.url"), "http://localhost:" + downloadPort), + Math.max(1, config.getInt("world.download.expiration-minutes", 30)), + Math.max(1, config.getInt("world.download.max-size-mb", 2048)), + Math.max(1, config.getInt("world.download.max-storage-mb", 8192)), + Math.max(1, config.getInt("world.download.max-concurrent-downloads", 3))); + Set deletionBlacklist = config.getStringList("world.deletion-blacklist").stream() .map(name -> name.toLowerCase(Locale.ROOT)) .collect(Collectors.toSet()); @@ -213,7 +223,8 @@ private static PluginConfig.World parseWorld(FileConfiguration config, Logger lo limits, defaults, unload, - backup); + backup, + download); } private static PluginConfig.World.VoidBlock parseVoidBlock(FileConfiguration config, Logger logger) { diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/PluginConfig.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/PluginConfig.java index 6748bed1..0cdffc7f 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/PluginConfig.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/PluginConfig.java @@ -65,7 +65,8 @@ public record World( Limits limits, Defaults defaults, Unload unload, - Backup backup) { + Backup backup, + Download download) { public World { deletionBlacklist = Set.copyOf(deletionBlacklist); @@ -180,6 +181,24 @@ public String toString() { public record AutoBackup(boolean enabled, boolean onlyActiveWorlds, int interval) {} } + + /** + * The built-in HTTP server behind {@code /worlds download}, disabled by default: enabling it opens a port and + * hands out world archives, so it stays an explicit decision by the operator. + * + * @param enabled Whether the download server runs at all + * @param port The port the server listens on + * @param url The base URL players are sent, for servers reached through a proxy or a domain + * @param expirationMinutes How long a download link stays valid before the archive is deleted + */ + public record Download( + boolean enabled, + int port, + String url, + int expirationMinutes, + int maxSizeMb, + int maxStorageMb, + int maxConcurrentDownloads) {} } public record Folder(boolean overridePermissions, boolean overrideProjects) {} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/menu/MenuItems.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/menu/MenuItems.java index b3508a87..b0f33c23 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/menu/MenuItems.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/menu/MenuItems.java @@ -19,6 +19,7 @@ import com.cryptomorin.xseries.XMaterial; import com.cryptomorin.xseries.profiles.builder.XSkull; +import com.cryptomorin.xseries.profiles.exceptions.ProfileException; import com.cryptomorin.xseries.profiles.objects.Profileable; import de.eintosti.buildsystem.api.player.settings.DesignColor; import de.eintosti.buildsystem.api.player.settings.Settings; @@ -28,6 +29,7 @@ import de.eintosti.buildsystem.player.settings.SettingsService; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletionException; import java.util.logging.Level; import java.util.stream.IntStream; import org.bukkit.Bukkit; @@ -152,12 +154,28 @@ public void applyHeadProfileAsync( Bukkit.getScheduler().runTask(plugin, () -> inventory.setItem(slot, itemStack)); }) .exceptionally(throwable -> { - plugin.getLogger() - .log(Level.WARNING, "Failed to resolve head profile for menu item: " + name, throwable); + logProfileFailure(name, throwable); return null; }); } + /** + * A head that Mojang does not know — an offline-mode UUID, a renamed account, a world name that is not a player — + * is routine: the slot keeps its plain placeholder head. Only unexpected failures (network, reflection) warrant a + * warning with a stack trace. + */ + private void logProfileFailure(String name, Throwable throwable) { + Throwable cause = throwable instanceof CompletionException && throwable.getCause() != null + ? throwable.getCause() + : throwable; + if (cause instanceof ProfileException || cause.getCause() instanceof ProfileException) { + plugin.getLogger() + .log(Level.FINE, () -> "No head profile for menu item " + name + ": " + cause.getMessage()); + return; + } + plugin.getLogger().log(Level.WARNING, "Failed to resolve head profile for menu item: " + name, throwable); + } + /** * Renders a {@link NavigatorCategory}'s icon into a menu slot, resolving any player-head skin asynchronously so the * live navigator never blocks the main thread on a profile lookup (an admin-configured username texture, or the diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/Permissions.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/Permissions.java index a583e066..6eeb90de 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/Permissions.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/Permissions.java @@ -53,6 +53,7 @@ private Permissions() {} public static final String CREATE_FOLDER = "buildsystem.create.folder"; public static final String DAY = "buildsystem.day"; public static final String DELETE = "buildsystem.delete"; + public static final String DOWNLOAD = "buildsystem.download"; public static final String EDIT = "buildsystem.edit"; public static final String EDIT_BREAKING = "buildsystem.edit.breaking"; public static final String EDIT_BUILDERS = "buildsystem.edit.builders"; diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/WorldFlush.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/WorldFlush.java new file mode 100644 index 00000000..71e8ea9a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/WorldFlush.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import java.lang.reflect.Method; +import org.bukkit.World; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Saves a world and waits for its chunks to reach disk. + * + *

{@link World#save()} returns while the chunk writer is still working, so a backup or export reading the world + * folder straight afterwards can pack half-written region files. Paper's {@code save(boolean flush)} waits for the + * writer to drain; it is not in the Spigot API this module compiles against, so it is resolved reflectively. Spigot + * itself writes chunks synchronously, so the plain save it falls back to is already flushed there. + */ +@NullMarked +public final class WorldFlush { + + private static final @Nullable Method SAVE_WITH_FLUSH = resolveSaveWithFlush(); + + private WorldFlush() {} + + /** + * Saves {@code world}, blocking until its chunks are written where the platform allows it. + */ + public static void saveAndFlush(World world) { + if (SAVE_WITH_FLUSH != null) { + try { + SAVE_WITH_FLUSH.invoke(world, true); + return; + } catch (ReflectiveOperationException ignored) { + // Fall through to the unflushed save rather than failing the export outright. + } + } + world.save(); + } + + private static @Nullable Method resolveSaveWithFlush() { + try { + return World.class.getMethod("save", boolean.class); + } catch (NoSuchMethodException e) { + return null; + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java index 82a5c319..6a10644b 100644 --- a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java @@ -33,6 +33,7 @@ import de.eintosti.buildsystem.i18n.Placeholders; import de.eintosti.buildsystem.util.FileUtils; import de.eintosti.buildsystem.util.StringCleaner; +import de.eintosti.buildsystem.util.WorldFlush; import de.eintosti.buildsystem.world.WorldServiceImpl; import de.eintosti.buildsystem.world.spawn.SpawnService; import java.io.File; @@ -106,12 +107,13 @@ public CompletableFuture> listBackups() { public CompletableFuture createBackup() { synchronized (this.creationLock) { // handle() before the compose: a failed backup must not poison every later backup of this world. - // World::save is main-thread-only and this is public API, so it must not run on the caller's thread. + // Saving is main-thread-only and this is public API, so it must not run on the caller's thread. The save + // also waits for the chunk writer: without that the archive can catch region files mid-write. CompletableFuture next = this.pendingCreation .handle((backup, throwable) -> null) .thenComposeAsync( ignored -> { - this.buildWorld.getWorld().ifPresent(World::save); + this.buildWorld.getWorld().ifPresent(WorldFlush::saveAndFlush); return storeWithRetention(); }, mainThreadExecutor()); diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/DownloadRegistry.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/DownloadRegistry.java new file mode 100644 index 00000000..c59555dd --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/DownloadRegistry.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * The live download links: which archive each token stands for, when it dies, and who is allowed to fetch it. + * + *

A link is a bearer token, so the first client to use one claims it — later requests from a different address are + * refused. That keeps a forwarded or shoulder-surfed link from being useful to anyone but the player who asked for it, + * while still allowing that player to retry or resume. + */ +@NullMarked +final class DownloadRegistry { + + private static final int TOKEN_BYTES = 32; + + private final SecureRandom random = new SecureRandom(); + private final Map downloads = new ConcurrentHashMap<>(); + private final LongSupplier clock; + + DownloadRegistry() { + this(System::currentTimeMillis); + } + + DownloadRegistry(LongSupplier clock) { + this.clock = clock; + } + + /** + * Registers an archive and returns the token it is reachable under. + * + * @param file The archive on disk + * @param fileName The name the client should save it as + * @param expiresAt When the link dies, in epoch milliseconds + * @return The generated token + */ + String register(Path file, String fileName, long expiresAt) { + byte[] bytes = new byte[TOKEN_BYTES]; + random.nextBytes(bytes); + String token = HexFormat.of().formatHex(bytes); + downloads.put(token, new Download(file, fileName, expiresAt, null)); + return token; + } + + /** + * Resolves a token for the client asking for it, pinning the link to that client on first use. + * + * @param token The token from the request + * @param clientAddress The requesting client's address + * @return The download, or {@code null} if the token is unknown, expired, or claimed by another client + */ + @Nullable Download claim(String token, String clientAddress) { + Download claimed = downloads.computeIfPresent(token, (ignored, download) -> { + if (download.isExpired(clock.getAsLong())) { + return download; + } + return download.claimedBy() == null ? download.claimFor(clientAddress) : download; + }); + + if (claimed == null || claimed.isExpired(clock.getAsLong()) || !clientAddress.equals(claimed.claimedBy())) { + return null; + } + return claimed; + } + + /** + * Drops every expired link, handing each archive to {@code deleter}. + */ + void purgeExpired(Consumer deleter) { + downloads.values().removeIf(download -> { + if (!download.isExpired(clock.getAsLong())) { + return false; + } + deleter.accept(download.file()); + return true; + }); + } + + /** + * {@return the total size of the registered archives} Used to hold all live downloads under the configured + * storage budget. + */ + long totalBytes() { + return downloads.values().stream().mapToLong(Download::size).sum(); + } + + void clear() { + downloads.clear(); + } + + /** + * @param file The archive on disk + * @param fileName The name the client saves it as + * @param expiresAt When the link dies, in epoch milliseconds + * @param claimedBy The address that first used the link, or {@code null} while it is unclaimed + */ + record Download( + Path file, + String fileName, + long expiresAt, + @Nullable String claimedBy) { + + boolean isExpired(long now) { + return now > expiresAt; + } + + Download claimFor(String clientAddress) { + return new Download(file, fileName, expiresAt, clientAddress); + } + + long size() { + try { + return Files.size(file); + } catch (IOException e) { + return 0L; + } + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/ExportProgressBar.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/ExportProgressBar.java new file mode 100644 index 00000000..8548741f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/ExportProgressBar.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import org.jspecify.annotations.NullMarked; + +/** + * Renders the animated bar shown while a world is being packed. + * + *

The animation is carried by a highlight sweeping through the filled part of the bar and a spinner beside it, so + * an export that is briefly stuck on one large region file still looks alive. Both are driven by the caller's frame + * counter rather than wall-clock time, keeping the output a pure function of {@code (fraction, frame)}. + */ +@NullMarked +public final class ExportProgressBar { + + private static final int SEGMENTS = 20; + private static final char FILLED = '▬'; + private static final char EMPTY = '▬'; + private static final String FILLED_COLOR = "&a"; + private static final String HIGHLIGHT_COLOR = "&f"; + private static final String EMPTY_COLOR = "&8"; + + /** + * Frames of the spinner. Plain ASCII, because the client's default font renders it everywhere, which is not true + * of the braille and circle glyphs usually used for spinners. + * + *

Every frame must be the same width. The default font is variable-width and the action bar is centered, so a + * narrower frame shifts the whole line sideways for as long as it shows: these three all advance six pixels, while + * the {@code |} that would complete the rotation advances two and made the message jitter. + */ + private static final char[] SPINNER = {'/', '-', '\\'}; + + private ExportProgressBar() {} + + /** + * @param fraction How much of the export is done, from {@code 0} to {@code 1} + * @param frame The animation frame, incremented once per update + * @return The legacy-colored bar + */ + public static String bar(double fraction, int frame) { + int filled = (int) Math.round(clamp(fraction) * SEGMENTS); + int highlight = filled == 0 ? -1 : Math.floorMod(frame, filled); + + StringBuilder bar = new StringBuilder(SEGMENTS * 3); + for (int segment = 0; segment < SEGMENTS; segment++) { + if (segment >= filled) { + bar.append(EMPTY_COLOR).append(EMPTY); + } else if (segment == highlight) { + bar.append(HIGHLIGHT_COLOR).append(FILLED); + } else { + bar.append(FILLED_COLOR).append(FILLED); + } + } + return bar.toString(); + } + + public static char spinner(int frame) { + return SPINNER[Math.floorMod(frame, SPINNER.length)]; + } + + public static int percent(double fraction) { + return (int) Math.round(clamp(fraction) * 100); + } + + /** + * {@return the percentage padded to a fixed three characters} Keeps the line from jumping as the number grows a + * digit. A space is narrower than a digit, so this shrinks the shift rather than removing it. + */ + public static String percentText(double fraction) { + return "%3d".formatted(percent(fraction)); + } + + private static double clamp(double fraction) { + if (Double.isNaN(fraction)) { + return 0; + } + return Math.clamp(fraction, 0, 1); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/RequestRateLimiter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/RequestRateLimiter.java new file mode 100644 index 00000000..b7c8d9e6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/RequestRateLimiter.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; +import org.jspecify.annotations.NullMarked; + +/** + * A fixed-window request limit per client address, so the download endpoint cannot be hammered by one host. + */ +@NullMarked +final class RequestRateLimiter { + + private final int maxRequests; + private final long windowMillis; + private final LongSupplier clock; + private final Map windows = new ConcurrentHashMap<>(); + + RequestRateLimiter(int maxRequests, long windowMillis) { + this(maxRequests, windowMillis, System::currentTimeMillis); + } + + RequestRateLimiter(int maxRequests, long windowMillis, LongSupplier clock) { + this.maxRequests = maxRequests; + this.windowMillis = windowMillis; + this.clock = clock; + } + + /** + * Counts a request from {@code clientAddress}. + * + * @return {@code true} if it is within the limit, {@code false} if the client is over budget + */ + boolean allow(String clientAddress) { + long now = clock.getAsLong(); + Window window = windows.compute(clientAddress, (ignored, current) -> { + if (current == null || now - current.startedAt() >= windowMillis) { + return new Window(now, 1); + } + return new Window(current.startedAt(), current.count() + 1); + }); + return window.count() <= maxRequests; + } + + /** + * Forgets windows that have run out, so the map does not grow with every address ever seen. + */ + void purgeStale() { + long now = clock.getAsLong(); + windows.values().removeIf(window -> now - window.startedAt() >= windowMillis); + } + + void clear() { + windows.clear(); + } + + private record Window(long startedAt, int count) {} +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java new file mode 100644 index 00000000..b9a6d707 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.config.ConfigService; +import de.eintosti.buildsystem.config.PluginConfig; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.util.TaskScheduler; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Serves world exports over HTTP so players can download a world as a single-player save. + * + *

Off by default. When enabled, an archive is only ever reachable through the unguessable, expiring token it was + * registered under: the token is the whole URL path, so no request can name a file, and nothing outside the plugin's + * {@code downloads} directory is served. A link is pinned to the first client that uses it, requests are rate limited + * per address, and concurrent transfers are capped so downloads cannot crowd each other out. Archives are deleted + * together with their token — on expiry, on reload and on shutdown. + */ +@NullMarked +public final class WorldDownloadService { + + private static final String CONTEXT_PATH = "/download/"; + private static final int MAX_REQUESTS_PER_WINDOW = 30; + private static final long RATE_LIMIT_WINDOW_MILLIS = Duration.ofMinutes(1).toMillis(); + private static final long PURGE_INTERVAL_TICKS = Duration.ofMinutes(1).toSeconds() * 20L; + + private final ConfigService configService; + private final TaskScheduler scheduler; + private final Logger logger; + private final File downloadFolder; + + private final DownloadRegistry registry = new DownloadRegistry(); + private final RequestRateLimiter rateLimiter = + new RequestRateLimiter(MAX_REQUESTS_PER_WINDOW, RATE_LIMIT_WINDOW_MILLIS); + + private @Nullable HttpServer server; + private @Nullable ExecutorService httpExecutor; + private @Nullable Semaphore transferSlots; + private @Nullable BukkitTask purgeTask; + + public WorldDownloadService(ConfigService configService, TaskScheduler scheduler, Logger logger, File dataFolder) { + this.configService = configService; + this.scheduler = scheduler; + this.logger = logger; + this.downloadFolder = new File(dataFolder, "downloads"); + } + + /** + * Starts the download server if it is enabled in the config. Any archive left behind by a previous run is deleted: + * its token did not survive the restart, so the file is unreachable. + */ + public void start() { + PluginConfig.World.Download config = config(); + if (!config.enabled()) { + return; + } + + clearDownloadFolder(); + if (!downloadFolder.isDirectory() && !downloadFolder.mkdirs()) { + logger.severe("Failed to create the world download folder: " + downloadFolder.getAbsolutePath()); + return; + } + + int concurrentDownloads = Math.max(1, config.maxConcurrentDownloads()); + ExecutorService executor = Executors.newFixedThreadPool(concurrentDownloads + 1, threadFactory()); + try { + HttpServer httpServer = HttpServer.create(new InetSocketAddress(config.port()), 0); + httpServer.createContext(CONTEXT_PATH, this::handle); + httpServer.setExecutor(executor); + httpServer.start(); + this.server = httpServer; + this.httpExecutor = executor; + this.transferSlots = new Semaphore(concurrentDownloads); + } catch (IOException e) { + executor.shutdownNow(); + logger.log(Level.SEVERE, "Failed to start the world download server on port " + config.port(), e); + return; + } + + this.purgeTask = scheduler.runTimer(this::purge, PURGE_INTERVAL_TICKS, PURGE_INTERVAL_TICKS); + logger.info("World downloads are available on port " + config.port()); + warnAboutPlaintext(config); + } + + /** + * Stops the download server and deletes every archive it was serving. + */ + public void stop() { + if (purgeTask != null) { + purgeTask.cancel(); + purgeTask = null; + } + if (server != null) { + server.stop(0); + server = null; + } + if (httpExecutor != null) { + httpExecutor.shutdownNow(); + httpExecutor = null; + } + transferSlots = null; + registry.clear(); + rateLimiter.clear(); + clearDownloadFolder(); + } + + /** + * Applies a changed config by restarting the server, so toggling downloads off takes effect immediately rather + * than at the next restart. + */ + public void reload() { + stop(); + start(); + } + + public boolean isEnabled() { + return server != null; + } + + public int getExpirationMinutes() { + return config().expirationMinutes(); + } + + /** + * Exports {@code buildWorld} on a background thread and registers it for download. + * + *

Must be called from the main thread: the world's folder and the main level's are resolved through the server + * before the export moves off it. + * + * @param buildWorld The world to export + * @param progress Notified as the world is packed, for showing the player how far along it is + * @return A future completed with the download URL, or completed exceptionally if the export fails + */ + public CompletableFuture prepare(BuildWorld buildWorld, WorldExporter.ExportProgress progress) { + if (server == null) { + return CompletableFuture.failedFuture(new IllegalStateException("World downloads are disabled")); + } + + PluginConfig.World.Download config = config(); + long storageBudget = megabytes(config.maxStorageMb()); + if (registry.totalBytes() >= storageBudget) { + return CompletableFuture.failedFuture(new StorageFullException()); + } + + File worldFolder = FileUtils.worldFolder(buildWorld.getName()); + List worlds = Bukkit.getWorlds(); + if (worlds.isEmpty()) { + return CompletableFuture.failedFuture(new IllegalStateException("No main level is loaded")); + } + File defaultLevelFolder = worlds.getFirst().getWorldFolder(); + + String worldName = buildWorld.getName(); + long maxArchiveBytes = Math.min(megabytes(config.maxSizeMb()), storageBudget - registry.totalBytes()); + long expiresAt = System.currentTimeMillis() + + Duration.ofMinutes(config.expirationMinutes()).toMillis(); + Path archive = new File(downloadFolder, worldName.hashCode() + "-" + System.nanoTime() + ".zip").toPath(); + + return CompletableFuture.supplyAsync( + () -> { + try { + WorldExporter.export( + worldName, worldFolder, defaultLevelFolder, archive, maxArchiveBytes, progress); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + String token = registry.register(archive, WorldExporter.fileName(worldName) + ".zip", expiresAt); + return url(token); + }, + scheduler.background()); + } + + /** + * Warns when links leave the server unencrypted. A token in a plaintext URL is readable by anything on the path, + * so the only safe plain-HTTP setup is one that terminates TLS in front of this server. + */ + private void warnAboutPlaintext(PluginConfig.World.Download config) { + if (config.url().toLowerCase(Locale.ROOT).startsWith("https://")) { + return; + } + logger.warning("World downloads are served over plain HTTP. Anyone able to observe the traffic can reuse a " + + "download link. Put the port behind a TLS proxy and point world.download.url at it."); + } + + private void handle(HttpExchange exchange) throws IOException { + String clientAddress = clientAddress(exchange); + try (exchange) { + if (!rateLimiter.allow(clientAddress)) { + respondEmpty(exchange, 429); + return; + } + + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + respondEmpty(exchange, 405); + return; + } + + DownloadRegistry.Download download = registry.claim(token(exchange), clientAddress); + if (download == null || !Files.isRegularFile(download.file())) { + respondEmpty(exchange, 404); + return; + } + + transfer(exchange, download); + } catch (IOException e) { + // A client that disconnects mid-download is routine and must not spam the console. + logger.log(Level.FINE, "World download aborted", e); + } + } + + private void transfer(HttpExchange exchange, DownloadRegistry.Download download) throws IOException { + Semaphore slots = transferSlots; + if (slots == null || !slots.tryAcquire()) { + exchange.getResponseHeaders().set("Retry-After", "30"); + respondEmpty(exchange, 503); + return; + } + + try { + exchange.getResponseHeaders().set("Content-Type", "application/zip"); + exchange.getResponseHeaders() + .set("Content-Disposition", "attachment; filename=\"" + download.fileName() + "\""); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.sendResponseHeaders(200, Files.size(download.file())); + + try (OutputStream out = exchange.getResponseBody()) { + Files.copy(download.file(), out); + } + } finally { + slots.release(); + } + } + + /** + * {@return the requested token, or the empty string for any request shaped differently} The token is the entire + * path below the context, so a request can neither name a file nor escape the download folder. + */ + private String token(HttpExchange exchange) { + String path = exchange.getRequestURI().getPath(); + return path.length() > CONTEXT_PATH.length() ? path.substring(CONTEXT_PATH.length()) : ""; + } + + private static String clientAddress(HttpExchange exchange) { + return exchange.getRemoteAddress().getAddress().getHostAddress(); + } + + private void purge() { + registry.purgeExpired(this::delete); + rateLimiter.purgeStale(); + } + + private String url(String token) { + String baseUrl = config().url(); + String trimmed = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + return trimmed + CONTEXT_PATH + token; + } + + private static long megabytes(int megabytes) { + return megabytes * 1024L * 1024L; + } + + private PluginConfig.World.Download config() { + return configService.current().world().download(); + } + + private void clearDownloadFolder() { + if (!downloadFolder.isDirectory()) { + return; + } + try { + FileUtils.deleteDirectory(downloadFolder); + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to clear the world download folder", e); + } + } + + private void delete(Path file) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to delete expired world download " + file, e); + } + } + + private static void respondEmpty(HttpExchange exchange, int status) throws IOException { + exchange.sendResponseHeaders(status, -1); + } + + private static ThreadFactory threadFactory() { + AtomicInteger counter = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, "BuildSystem-Download-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + /** + * Thrown when the live archives already fill the configured storage budget. + */ + public static final class StorageFullException extends IOException { + + StorageFullException() { + super("The world download storage budget is exhausted"); + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldExporter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldExporter.java new file mode 100644 index 00000000..3e295fde --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldExporter.java @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import dev.dewy.nbt.Nbt; +import dev.dewy.nbt.tags.collection.CompoundTag; +import dev.dewy.nbt.tags.collection.ListTag; +import dev.dewy.nbt.tags.primitive.StringTag; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Packs a server-side world into an archive that can be dropped into a client's {@code saves} directory. + * + *

Since Paper 26.1 a server world is a dimension of the main level ({@code /dimensions/minecraft/}) and + * has no {@code level.dat} of its own, so its folder alone is not a save the client can open. The export rebuilds the + * missing scaffolding: the dimension becomes the save's overworld and the main level's {@code level.dat} is copied in + * under the exported world's name. Worlds still stored in the pre-26.1 flat layout already are a save and are packed + * as they are. + */ +@NullMarked +public final class WorldExporter { + + /** + * Runtime and leftover files. {@code level.dat_old} matters most: it is an untouched copy of the original, so + * leaving it in would hand back the very metadata the exported {@code level.dat} has been stripped of. + */ + private static final Set EXCLUDED_FILES = + Set.of("session.lock", "uid.dat", "paper-world.yml", "level.dat_old", ".DS_Store"); + + private static final Set VANILLA_DIMENSIONS = Set.of("overworld", "the_nether", "the_end"); + + /** + * How far below the level root a dimension folder sits: {@code dimensions/minecraft/}. + */ + private static final int DIMENSION_FOLDER_DEPTH = 3; + + /** + * The only saved data a client reads from inside a dimension. Everything else a Paper dimension folder holds is + * level-scoped and belongs in the save's root {@code data/minecraft}: Paper keeps a per-world copy of those files + * because each Bukkit world is a level of its own, but a single-player save has one set for the whole world, and a + * client that finds no {@code world_gen_settings.dat} there cannot build the level at all. + */ + private static final Set DIMENSION_SCOPED_DATA = + Set.of("raids.dat", "world_border.dat", "chunk_tickets.dat"); + + private static final String MINECRAFT_DATA_PREFIX = "data" + File.separator + "minecraft" + File.separator; + + private static final Pattern UNSAFE_NAME_CHARACTERS = Pattern.compile("[^A-Za-z0-9._-]"); + + /** + * Top-level directories that never leave the server. The main level stores every player's inventory, position and + * statistics, so exporting it would hand out the whole server's player data; {@code datapacks/bukkit} is the + * server-side pack a client has no use for. + */ + private static final Set EXCLUDED_DIRECTORIES = Set.of("players", "playerdata", "stats", "advancements"); + + /** + * Datapacks the client cannot resolve, dropped from both the folder and the enabled list in {@code level.dat}. + */ + private static final Set SERVER_DATAPACKS = Set.of("bukkit", "file/bukkit", "paper", "file/paper"); + + /** + * Server-identifying tags removed from the exported {@code level.dat}. + */ + private static final Set SERVER_METADATA = Set.of("ServerBrands", "Bukkit.Version", "WasModded"); + + private WorldExporter() {} + + /** + * Writes {@code worldFolder} to {@code target} as a zipped single-player save. + * + * @param worldName The world's name, used for the archive's root directory and the save's displayed name + * @param worldFolder The world's folder on disk + * @param defaultLevelFolder The main level's folder, the source of the {@code level.dat} a dimension world lacks + * @param target The archive to write + * @param maxBytes The size the archive may not exceed + * @param progress Notified as the world is packed, for a caller showing the player how far along it is + * @throws WorldTooLargeException If the archive would grow past {@code maxBytes} + * @throws IOException If the world cannot be read or the archive cannot be written + */ + public static void export( + String worldName, + File worldFolder, + File defaultLevelFolder, + Path target, + long maxBytes, + ExportProgress progress) + throws IOException { + Path source = worldFolder.toPath(); + if (!Files.isDirectory(source)) { + throw new IOException("World folder does not exist: " + worldFolder.getAbsolutePath()); + } + + String rootDirectory = fileName(worldName); + Files.createDirectories(target.getParent()); + boolean isLevelFolder = Files.isRegularFile(source.resolve("level.dat")); + List files = collectFiles(source, isLevelFolder); + progress.update(0L, totalBytes(files)); + + try (CountingOutputStream counter = new CountingOutputStream(Files.newOutputStream(target), maxBytes); + ZipOutputStream zip = new ZipOutputStream(counter)) { + if (isLevelFolder) { + // Already a level folder: the pre-26.1 flat layout, or a world that is itself the main level. Its own + // nether and end come along; dimensions belonging to other worlds do not. + writeEntry(zip, rootDirectory + "/level.dat", levelDat(source, worldName)); + copyTree(zip, source, files, progress, relative -> rootDirectory + "/" + slashed(relative)); + } else { + writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder.toPath(), worldName)); + copyTree(zip, source, files, progress, relative -> dimensionEntry(rootDirectory, relative)); + } + } catch (IOException e) { + Files.deleteIfExists(target); + throw e; + } + } + + /** + * Reports how much of a world has been packed. Called from the export thread, once per file. + */ + @FunctionalInterface + public interface ExportProgress { + + ExportProgress IGNORED = (packedBytes, totalBytes) -> {}; + + /** + * @param packedBytes Bytes packed so far + * @param totalBytes Bytes the finished export will have read + */ + void update(long packedBytes, long totalBytes); + } + + /** + * {@return {@code name} reduced to characters that are safe in an archive entry and an HTTP header} + */ + public static String fileName(String name) { + String sanitized = UNSAFE_NAME_CHARACTERS.matcher(name).replaceAll("_"); + return sanitized.isBlank() ? "world" : sanitized; + } + + private static void copyTree( + ZipOutputStream zip, + Path root, + List files, + ExportProgress progress, + Function entryNamer) + throws IOException { + long total = totalBytes(files); + long packed = 0L; + for (Path file : files) { + zip.putNextEntry(new ZipEntry(entryNamer.apply(root.relativize(file)))); + Files.copy(file, zip); + zip.closeEntry(); + + packed += sizeOf(file); + progress.update(packed, total); + } + } + + /** + * Places a file from a Paper dimension folder in the save, splitting its {@code data/minecraft} contents by scope: + * the client reads generator settings, game rules and the like from the save root, and only a few files from + * inside the dimension itself. + */ + private static String dimensionEntry(String rootDirectory, Path relative) { + String path = relative.toString(); + if (path.startsWith(MINECRAFT_DATA_PREFIX) + && !DIMENSION_SCOPED_DATA.contains(relative.getFileName().toString())) { + return rootDirectory + "/data/minecraft/" + relative.getFileName(); + } + return rootDirectory + "/dimensions/minecraft/overworld/" + slashed(relative); + } + + private static String slashed(Path relative) { + return relative.toString().replace(File.separatorChar, '/'); + } + + /** + * The files the export will write, resolved up front so its size is known before the first byte is packed. + */ + private static List collectFiles(Path root, boolean isLevelFolder) throws IOException { + try (Stream walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile) + .filter(file -> !isExcluded(root.relativize(file), isLevelFolder)) + .toList(); + } + } + + private static long totalBytes(List files) { + return files.stream().mapToLong(WorldExporter::sizeOf).sum(); + } + + private static long sizeOf(Path file) { + try { + return Files.size(file); + } catch (IOException e) { + return 0L; + } + } + + /** + * Whether a file stays on the server. Beyond the runtime files every export drops, a level folder also holds the + * server's player data and the dimensions of unrelated worlds, and its {@code level.dat} is rewritten separately. + */ + private static boolean isExcluded(Path relative, boolean isLevelFolder) { + if (EXCLUDED_FILES.contains(relative.getFileName().toString())) { + return true; + } + if (!isLevelFolder) { + return false; + } + if (relative.getNameCount() == 1 && relative.getName(0).toString().equals("level.dat")) { + return true; + } + + String first = relative.getName(0).toString().toLowerCase(Locale.ROOT); + if (EXCLUDED_DIRECTORIES.contains(first)) { + return true; + } + if (first.equals("datapacks") + && relative.getNameCount() > 1 + && SERVER_DATAPACKS.contains(relative.getName(1).toString().toLowerCase(Locale.ROOT))) { + return true; + } + return relative.getNameCount() > 2 + && first.equals("dimensions") + && relative.getName(1).toString().equals("minecraft") + && !VANILLA_DIMENSIONS.contains(relative.getName(2).toString().toLowerCase(Locale.ROOT)); + } + + private static void writeEntry(ZipOutputStream zip, String entryName, byte[] content) throws IOException { + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(content); + zip.closeEntry(); + } + + /** + * Reads a {@code level.dat} and prepares it for the client: renamed to the exported world, so several exports do + * not all show up under the server's level name, and stripped of the tags that only describe this server. + */ + private static byte[] levelDat(Path levelFolder, String worldName) throws IOException { + Path levelDat = findLevelDat(levelFolder); + if (levelDat == null) { + throw new IOException("No level.dat to export at or above: " + levelFolder); + } + + Nbt nbt = new Nbt(); + CompoundTag root = nbt.fromFile(levelDat.toFile()); + CompoundTag data = root.getCompound("Data"); + if (data == null) { + throw new IOException("level.dat has no Data compound: " + levelDat); + } + + data.putString("LevelName", worldName); + SERVER_METADATA.forEach(data::remove); + removeServerDatapacks(data.getCompound("DataPacks")); + + // Nbt#toByteArray writes uncompressed; a save's level.dat is gzipped. + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(new GZIPOutputStream(bytes))) { + nbt.toStream(root, out); + } + return bytes.toByteArray(); + } + + /** + * Finds the {@code level.dat} for a folder, searching upwards. + * + *

Paper reports a world's folder inconsistently across setups: for the main level it may be the level root or + * the overworld's dimension folder ({@code /dimensions/minecraft/overworld}). Both lead to the same + * {@code level.dat}, one directly and one three levels up, so the search walks far enough to cover the deeper + * shape and no further. + * + * @param folder The folder to start at + * @return The level.dat, or {@code null} if there is none within reach + */ + private static @Nullable Path findLevelDat(Path folder) { + Path candidate = folder; + for (int depth = 0; depth <= DIMENSION_FOLDER_DEPTH && candidate != null; depth++) { + Path levelDat = candidate.resolve("level.dat"); + if (Files.isRegularFile(levelDat)) { + return levelDat; + } + candidate = candidate.getParent(); + } + return null; + } + + /** + * Drops the server-side packs from the save's enabled list. A client cannot resolve them and would prompt about + * missing packs on load. + */ + private static void removeServerDatapacks(@Nullable CompoundTag dataPacks) { + if (dataPacks == null) { + return; + } + for (String key : List.of("Enabled", "Disabled")) { + ListTag packs = dataPacks.getList(key); + if (packs == null) { + continue; + } + List kept = new ArrayList<>(); + for (StringTag pack : packs) { + if (!SERVER_DATAPACKS.contains(pack.getValue().toLowerCase(Locale.ROOT))) { + kept.add(pack); + } + } + dataPacks.putList(key, kept); + } + } + + /** + * Thrown when an export outgrows the configured limit, so a single world cannot fill the server's disk. + */ + public static final class WorldTooLargeException extends IOException { + + WorldTooLargeException(long maxBytes) { + super("World export exceeds the configured limit of " + maxBytes + " bytes"); + } + } + + /** + * Counts what is written and aborts the export once it passes the limit, rather than after a full world has + * already landed on disk. + */ + private static final class CountingOutputStream extends OutputStream { + + private final OutputStream delegate; + private final long maxBytes; + private long written; + + CountingOutputStream(OutputStream delegate, long maxBytes) { + this.delegate = delegate; + this.maxBytes = maxBytes; + } + + @Override + public void write(int b) throws IOException { + count(1); + delegate.write(b); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + count(length); + delegate.write(bytes, offset, length); + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + private void count(int bytes) throws IOException { + written += bytes; + if (written > maxBytes) { + throw new WorldTooLargeException(maxBytes); + } + } + } +} diff --git a/buildsystem-core/src/main/resources/config.yml b/buildsystem-core/src/main/resources/config.yml index f30673fe..b5ae91e4 100644 --- a/buildsystem-core/src/main/resources/config.yml +++ b/buildsystem-core/src/main/resources/config.yml @@ -110,6 +110,21 @@ world: password: YOUR_SFTP_PASSWORD path: backups/worlds/ + # Lets players download a world as a single-player save via /worlds download. + # Enabling this opens a port on the server that serves world archives, each + # reachable only through an unguessable link that expires with the archive. + download: + enabled: false + port: 8080 + # The address players are sent to. Change it if the port is reached through + # a proxy or a domain, e.g. "https://downloads.example.com" + url: "http://localhost:8080" + expiration-minutes: 30 + # Largest single export, and the budget shared by all live downloads. + max-size-mb: 2048 + max-storage-mb: 8192 + max-concurrent-downloads: 3 + folder: override-permissions: true override-projects: false diff --git a/buildsystem-core/src/main/resources/messages.yml b/buildsystem-core/src/main/resources/messages.yml index 1ad76afe..3f26ccb3 100644 --- a/buildsystem-core/src/main/resources/messages.yml +++ b/buildsystem-core/src/main/resources/messages.yml @@ -203,6 +203,18 @@ worlds_delete_started: "%prefix% &7The deletion of &b%world% &7has started..." worlds_delete_finished: "%prefix% &7The world was &asuccessfully &7deleted." worlds_delete_players_world: "%prefix% &7&oThe world you were in was deleted." +worlds_download_usage: "%prefix% &7Usage: &b/worlds download " +worlds_download_unknown_world: "%prefix% &cUnknown world." +worlds_download_disabled: "%prefix% &cWorld downloads are disabled on this server." +worlds_download_in_progress: "%prefix% &cYou are already preparing a download." +worlds_download_preparing: "%prefix% &7Preparing &b%world% &7for download..." +worlds_download_progress: "&8[%bar%&8] &f%percent%% &7Packing &b%world% &7%spinner%" +worlds_download_failed: "%prefix% &cUnable to prepare %world% for download. See the console for details." +worlds_download_too_large: "%prefix% &c%world% is too large to download." +worlds_download_storage_full: "%prefix% &cToo many downloads are pending. Try again once one expires." +worlds_download_finished: "%prefix% &b%world% &7is ready: %button%&7. &8(&7Expires in &f%minutes% minutes&8)" +worlds_download_button: "&a&nClick here to download" + worlds_edit_usage: "%prefix% &7Usage: &b/worlds edit " worlds_edit_unknown_world: "%prefix% &cUnknown world." @@ -247,6 +259,7 @@ worlds_help_setstatus: "&7Set a world's status." worlds_help_setspawn: "&7Set a world's spawnpoint." worlds_help_removespawn: "&7Removes a world's spawnpoint." worlds_help_delete: "&7Delete a world." +worlds_help_download: "&7Download a world as a single-player save." worlds_help_import: "&7Import a world." worlds_help_importall: "&7Import all worlds at once." worlds_help_unimport: "&7Unimport a world." diff --git a/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/DownloadRegistryTest.java b/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/DownloadRegistryTest.java new file mode 100644 index 00000000..05b86b56 --- /dev/null +++ b/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/DownloadRegistryTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers what stands between a leaked link and the world behind it: the link belongs to the first client that uses + * it, it dies on time, and the rate limiter cuts off a client that hammers the endpoint. + */ +@NullMarked +class DownloadRegistryTest { + + private static final String OWNER = "203.0.113.7"; + private static final String STRANGER = "198.51.100.4"; + + @TempDir + Path tempDir; + + private final AtomicLong now = new AtomicLong(1_000L); + + @Test + void pinsALinkToTheClientThatUsesItFirst() throws IOException { + DownloadRegistry registry = new DownloadRegistry(now::get); + String token = registry.register(archive("world.zip"), "world.zip", now.get() + 1_000L); + + assertNotNull(registry.claim(token, OWNER), "the first client should get the download"); + assertNull(registry.claim(token, STRANGER), "a forwarded link should be useless to anyone else"); + assertNotNull(registry.claim(token, OWNER), "the owner should still be able to retry"); + } + + @Test + void refusesExpiredAndUnknownTokens() throws IOException { + DownloadRegistry registry = new DownloadRegistry(now::get); + String token = registry.register(archive("world.zip"), "world.zip", now.get() + 1_000L); + + assertNull(registry.claim("not-a-token", OWNER)); + + now.addAndGet(1_001L); + assertNull(registry.claim(token, OWNER)); + } + + @Test + void purgeDeletesExpiredArchivesOnly() throws IOException { + DownloadRegistry registry = new DownloadRegistry(now::get); + Path expiring = archive("expiring.zip"); + Path surviving = archive("surviving.zip"); + registry.register(expiring, "expiring.zip", now.get() + 1_000L); + registry.register(surviving, "surviving.zip", now.get() + 10_000L); + + now.addAndGet(1_001L); + List deleted = new ArrayList<>(); + registry.purgeExpired(deleted::add); + + assertEquals(List.of(expiring), deleted); + assertEquals(Files.size(surviving), registry.totalBytes()); + } + + @Test + void rateLimiterCutsOffAClientAndRecoversNextWindow() { + RequestRateLimiter limiter = new RequestRateLimiter(2, 1_000L, now::get); + + assertTrue(limiter.allow(OWNER)); + assertTrue(limiter.allow(OWNER)); + assertTrue(!limiter.allow(OWNER), "the third request in the window should be refused"); + assertTrue(limiter.allow(STRANGER), "another client keeps its own budget"); + + now.addAndGet(1_001L); + assertTrue(limiter.allow(OWNER)); + } + + private Path archive(String name) throws IOException { + Path file = tempDir.resolve(name); + Files.writeString(file, "archive-" + name); + return file; + } +} diff --git a/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/WorldExporterTest.java b/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/WorldExporterTest.java new file mode 100644 index 00000000..4dd0feb0 --- /dev/null +++ b/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/WorldExporterTest.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2018-2026, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.download; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Covers the layout the client expects: a Paper 26.1+ dimension world has to come back out as a save with a + * {@code level.dat} and its data under the overworld. + */ +@NullMarked +class WorldExporterTest { + + private static final long MAX_BYTES = 64L * 1024 * 1024; + + @TempDir + Path tempDir; + + @Test + void exportsDimensionWorldAsSinglePlayerSave() throws IOException { + Path dimension = tempDir.resolve("dimensions/minecraft/lobby"); + Files.createDirectories(dimension.resolve("region")); + Files.writeString(dimension.resolve("region/r.0.0.mca"), "region-data"); + Files.createDirectories(dimension.resolve("data/minecraft")); + Files.writeString(dimension.resolve("data/minecraft/world_gen_settings.dat"), "gen"); + Files.writeString(dimension.resolve("data/minecraft/game_rules.dat"), "rules"); + Files.writeString(dimension.resolve("data/minecraft/world_border.dat"), "border"); + Files.writeString(dimension.resolve("session.lock"), "lock"); + Files.writeString(dimension.resolve("paper-world.yml"), "paper: config"); + + File level = levelFolder("MainLevel"); + Path archive = tempDir.resolve("out.zip"); + WorldExporter.export( + "lobby", dimension.toFile(), level, archive, MAX_BYTES, WorldExporter.ExportProgress.IGNORED); + + Map entries = read(archive); + assertEquals("region-data", new String(entries.get("lobby/dimensions/minecraft/overworld/region/r.0.0.mca"))); + // The client reads generator settings and game rules from the save root; Paper keeps its per-world copies + // inside the dimension folder, and a save that leaves them there does not load. + assertTrue(entries.containsKey("lobby/data/minecraft/world_gen_settings.dat")); + assertTrue(entries.containsKey("lobby/data/minecraft/game_rules.dat")); + assertFalse(entries.containsKey("lobby/dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat")); + assertTrue( + entries.containsKey("lobby/dimensions/minecraft/overworld/data/minecraft/world_border.dat"), + "dimension-scoped data stays in the dimension"); + assertEquals("lobby", levelName(entries.get("lobby/level.dat"))); + assertFalse(entries.containsKey("lobby/dimensions/minecraft/overworld/session.lock")); + assertFalse(entries.containsKey("lobby/dimensions/minecraft/overworld/paper-world.yml")); + } + + @Test + void keepsFlatWorldLayoutAndDropsForeignDimensions() throws IOException { + Path world = tempDir.resolve("legacy"); + Files.createDirectories(world.resolve("region")); + Files.write(world.resolve("level.dat"), gzip(levelDat("legacy"))); + Files.writeString(world.resolve("region/r.0.0.mca"), "region-data"); + Files.createDirectories(world.resolve("dimensions/minecraft/other")); + Files.writeString(world.resolve("dimensions/minecraft/other/marker"), "another world"); + Files.createDirectories(world.resolve("dimensions/minecraft/the_nether")); + Files.writeString(world.resolve("dimensions/minecraft/the_nether/marker"), "own nether"); + + Path archive = tempDir.resolve("legacy.zip"); + WorldExporter.export( + "legacy", + world.toFile(), + levelFolder("MainLevel"), + archive, + MAX_BYTES, + WorldExporter.ExportProgress.IGNORED); + + Map entries = read(archive); + assertTrue(entries.containsKey("legacy/region/r.0.0.mca")); + assertTrue(entries.containsKey("legacy/dimensions/minecraft/the_nether/marker")); + assertFalse(entries.containsKey("legacy/dimensions/minecraft/other/marker")); + } + + @Test + void findsTheLevelDatWhenPaperReportsTheOverworldDimensionFolder() throws IOException { + Path level = tempDir.resolve("level-Main"); + Files.createDirectories(level); + Files.write(level.resolve("level.dat"), gzip(levelDat("Main"))); + Path overworld = level.resolve("dimensions/minecraft/overworld"); + Files.createDirectories(overworld); + + Path dimension = level.resolve("dimensions/minecraft/lobby"); + Files.createDirectories(dimension.resolve("region")); + Files.writeString(dimension.resolve("region/r.0.0.mca"), "region-data"); + + Path archive = tempDir.resolve("reported-dimension.zip"); + WorldExporter.export( + "lobby", + dimension.toFile(), + overworld.toFile(), + archive, + MAX_BYTES, + WorldExporter.ExportProgress.IGNORED); + + assertEquals("lobby", levelName(read(archive).get("lobby/level.dat"))); + } + + @Test + void reportsProgressUpToTheTotal() throws IOException { + Path dimension = tempDir.resolve("dimensions/minecraft/progress"); + Files.createDirectories(dimension.resolve("region")); + for (int region = 0; region < 4; region++) { + Files.writeString(dimension.resolve("region/r." + region + ".0.mca"), "x".repeat(1000)); + } + + List packed = new ArrayList<>(); + AtomicLong total = new AtomicLong(); + WorldExporter.export( + "progress", + dimension.toFile(), + levelFolder("MainLevel"), + tempDir.resolve("progress.zip"), + MAX_BYTES, + (packedBytes, totalBytes) -> { + packed.add(packedBytes); + total.set(totalBytes); + }); + + assertEquals(4000L, total.get()); + assertEquals(List.of(0L, 1000L, 2000L, 3000L, 4000L), packed); + } + + @Test + void barFillsAndAnimatesWithinTheFilledPart() { + assertEquals(0, ExportProgressBar.percent(0)); + assertEquals(50, ExportProgressBar.percent(0.5)); + assertEquals(100, ExportProgressBar.percent(1.5), "an over-full fraction is clamped"); + assertEquals(0, ExportProgressBar.percent(Double.NaN), "an unknown total reads as zero, not NaN"); + + assertFalse(ExportProgressBar.bar(0, 0).contains("&f"), "an empty bar has nothing to highlight"); + assertTrue(ExportProgressBar.bar(0.5, 0).contains("&f"), "a half-full bar carries the sweep"); + assertNotEquals( + ExportProgressBar.bar(0.5, 0), + ExportProgressBar.bar(0.5, 1), + "the sweep moves between frames at the same progress"); + assertNotEquals(ExportProgressBar.spinner(0), ExportProgressBar.spinner(1)); + assertEquals( + 3, + ExportProgressBar.percentText(0.07).length(), + "the percentage is padded so the centered line does not jump a digit's width"); + assertEquals( + ExportProgressBar.percentText(0.07).length(), + ExportProgressBar.percentText(1).length()); + } + + private File levelFolder(String levelName) throws IOException { + Path level = tempDir.resolve("level-" + levelName); + Files.createDirectories(level); + Files.write(level.resolve("level.dat"), gzip(levelDat(levelName))); + return level.toFile(); + } + + /** + * A minimal {@code level.dat} body: enough NBT around the {@code LevelName} tag for the rename to have to find it. + */ + private static byte[] levelDat(String levelName) { + byte[] name = levelName.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.writeBytes(new byte[] {10, 0, 0, 10, 0, 4, 'D', 'a', 't', 'a'}); + out.writeBytes(new byte[] {8, 0, 9, 'L', 'e', 'v', 'e', 'l', 'N', 'a', 'm', 'e'}); + out.write(name.length >> 8); + out.write(name.length); + out.writeBytes(name); + out.writeBytes(new byte[] {3, 0, 4, 'T', 'i', 'm', 'e', 0, 0, 0, 7, 0, 0}); + return out.toByteArray(); + } + + private static String levelName(byte[] compressed) throws IOException { + byte[] nbt; + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + nbt = in.readAllBytes(); + } + String text = new String(nbt, StandardCharsets.ISO_8859_1); + int header = text.indexOf("LevelName") + "LevelName".length(); + int length = ((nbt[header] & 0xFF) << 8) | (nbt[header + 1] & 0xFF); + return new String(nbt, header + 2, length, StandardCharsets.UTF_8); + } + + private static byte[] gzip(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (OutputStream out = new GZIPOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } + + private static Map read(Path archive) throws IOException { + Map entries = new HashMap<>(); + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entries.put(entry.getName(), zip.readAllBytes()); + } + } + return entries; + } +}