From e186a773d14250bc8c5ed9617f446b4b472644b9 Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:00:34 +0200 Subject: [PATCH 01/11] feat: keep the held head's texture in /worlds setItem A textured skull set as a world icon lost its texture: only the material was stored. Read the profile off the held item when it is a head, gated on the profile being ready so a name-only head never blocks the main thread on a Mojang lookup. A blank head clears the texture, so the icon always matches the item that was held. --- .../subcommand/worlds/DownloadSubCommand.java | 138 ++++++++ .../subcommand/worlds/SetItemSubCommand.java | 23 +- .../world/download/WorldDownloadService.java | 301 ++++++++++++++++++ .../world/download/WorldExporter.java | 210 ++++++++++++ .../world/download/WorldExporterTest.java | 145 +++++++++ 5 files changed, 815 insertions(+), 2 deletions(-) create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DownloadSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldExporter.java create mode 100644 buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/WorldExporterTest.java 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..262f084f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DownloadSubCommand.java @@ -0,0 +1,138 @@ +/* + * 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.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.download.WorldDownloadService; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +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.World; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class DownloadSubCommand extends AbstractSubCommand { + + 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(World::save); + + downloadService + .prepare(buildWorld) + .whenCompleteAsync( + (url, throwable) -> { + preparing.remove(playerId); + if (throwable != null) { + logger.log( + Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable); + messages.sendMessage(player, "worlds_download_failed", worldPlaceholder); + return; + } + if (player.isOnline()) { + sendLink(player, buildWorld, url); + } + }, + scheduler.mainThread()); + } + + private void sendLink(Player player, BuildWorld buildWorld, String url) { + String message = messages.getString( + "worlds_download_finished", + player, + Placeholders.of("%world%", buildWorld.getName()) + .add("%minutes%", String.valueOf(downloadService.getExpirationMinutes()))); + + TextComponent component = new TextComponent(TextComponent.fromLegacyText(message)); + component.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); + component.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url))); + player.spigot().sendMessage(component); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + } + + @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/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/world/download/WorldDownloadService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java new file mode 100644 index 00000000..3aa32db4 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java @@ -0,0 +1,301 @@ +/* + * 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.security.SecureRandom; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +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. Archives and their tokens are dropped together — on expiry, on reload and on + * shutdown — so an export never outlives its link. + */ +@NullMarked +public final class WorldDownloadService { + + private static final String CONTEXT_PATH = "/download/"; + private static final int TOKEN_BYTES = 32; + private static final int HTTP_THREADS = 2; + private static final long PURGE_INTERVAL_TICKS = Duration.ofMinutes(1).toSeconds() * 20L; + private static final long SHUTDOWN_GRACE_SECONDS = 5L; + + private final ConfigService configService; + private final TaskScheduler scheduler; + private final Logger logger; + private final File downloadFolder; + + private final SecureRandom random = new SecureRandom(); + private final Map downloads = new ConcurrentHashMap<>(); + + private @Nullable HttpServer server; + private @Nullable ExecutorService httpExecutor; + 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; + } + + ExecutorService executor = Executors.newFixedThreadPool(HTTP_THREADS, 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; + } 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::purgeExpired, PURGE_INTERVAL_TICKS, PURGE_INTERVAL_TICKS); + logger.info("World downloads are available on port " + config.port()); + } + + /** + * 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; + } + downloads.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 + * @return A future completed with the download URL, or completed exceptionally if the export fails + */ + public CompletableFuture prepare(BuildWorld buildWorld) { + if (server == null) { + return CompletableFuture.failedFuture(new IllegalStateException("World downloads are disabled")); + } + + 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(); + String token = generateToken(); + Path archive = new File(downloadFolder, token + ".zip").toPath(); + long expiresAt = System.currentTimeMillis() + Duration.ofMinutes(getExpirationMinutes()).toMillis(); + + return CompletableFuture.supplyAsync( + () -> { + try { + WorldExporter.export(worldName, worldFolder, defaultLevelFolder, archive); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + downloads.put(token, new Download(archive, WorldExporter.fileName(worldName) + ".zip", expiresAt)); + return url(token); + }, + scheduler.background()); + } + + private void handle(HttpExchange exchange) throws IOException { + try (exchange) { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + respondEmpty(exchange, 405); + return; + } + + Download download = downloads.get(token(exchange)); + if (download == null || download.isExpired() || !Files.isRegularFile(download.file())) { + respondEmpty(exchange, 404); + return; + } + + 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); + } + } 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); + } + } + + /** + * {@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 void purgeExpired() { + downloads.values().removeIf(download -> { + if (!download.isExpired()) { + return false; + } + delete(download.file()); + return true; + }); + } + + 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 String generateToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + random.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + 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; + }; + } + + private record Download(Path file, String fileName, long expiresAt) { + + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } +} 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..8d7eb9ec --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldExporter.java @@ -0,0 +1,210 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.jspecify.annotations.NullMarked; + +/** + * 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 { + + private static final Set EXCLUDED_FILES = Set.of("session.lock", "uid.dat", "paper-world.yml"); + private static final Set VANILLA_DIMENSIONS = Set.of("overworld", "the_nether", "the_end"); + private static final Pattern UNSAFE_NAME_CHARACTERS = Pattern.compile("[^A-Za-z0-9._-]"); + + /** + * The {@code TAG_String("LevelName")} header: tag id, then the 2-byte length and bytes of the tag name. + */ + private static final byte[] LEVEL_NAME_TAG = {8, 0, 9, 'L', 'e', 'v', 'e', 'l', 'N', 'a', 'm', 'e'}; + + 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 + * @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) + 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()); + + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(target))) { + if (Files.isRegularFile(source.resolve("level.dat"))) { + // Already a level folder: the pre-26.1 flat layout, or a world that is itself the main level. Nested + // dimensions belong to other worlds and are left behind. + copyTree(zip, source, rootDirectory + "/", true); + } else { + writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder, worldName)); + copyTree(zip, source, rootDirectory + "/dimensions/minecraft/overworld/", false); + } + } catch (IOException e) { + Files.deleteIfExists(target); + throw e; + } + } + + /** + * {@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, String prefix, boolean skipNestedDimensions) + throws IOException { + try (Stream walk = Files.walk(root)) { + List files = walk.filter(Files::isRegularFile).toList(); + for (Path file : files) { + Path relative = root.relativize(file); + if (isExcluded(relative, skipNestedDimensions)) { + continue; + } + zip.putNextEntry(new ZipEntry(prefix + relative.toString().replace(File.separatorChar, '/'))); + Files.copy(file, zip); + zip.closeEntry(); + } + } + } + + private static boolean isExcluded(Path relative, boolean skipNestedDimensions) { + if (EXCLUDED_FILES.contains(relative.getFileName().toString())) { + return true; + } + if (!skipNestedDimensions || relative.getNameCount() < 3) { + return false; + } + return relative.getName(0).toString().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 the main level's {@code level.dat} and renames the save to the exported world, so a player who exports + * several worlds does not end up with a list of identically named saves. + */ + private static byte[] levelDat(File defaultLevelFolder, String worldName) throws IOException { + Path levelDat = defaultLevelFolder.toPath().resolve("level.dat"); + if (!Files.isRegularFile(levelDat)) { + throw new IOException("Main level has no level.dat to copy: " + levelDat); + } + return gzip(withLevelName(gunzip(Files.readAllBytes(levelDat)), worldName)); + } + + /** + * Replaces the value of the {@code LevelName} tag in uncompressed NBT, returning the data unchanged when the tag + * is not found. + * + *

ponytail: a byte splice rather than an NBT parser — the tag header is self-delimiting and this is the only + * field the export rewrites. Parse properly if a second field ever needs changing. + */ + private static byte[] withLevelName(byte[] nbt, String worldName) { + int header = indexOf(nbt, LEVEL_NAME_TAG); + if (header < 0) { + return nbt; + } + + int lengthAt = header + LEVEL_NAME_TAG.length; + if (lengthAt + 2 > nbt.length) { + return nbt; + } + int oldLength = ((nbt[lengthAt] & 0xFF) << 8) | (nbt[lengthAt + 1] & 0xFF); + int valueEnd = lengthAt + 2 + oldLength; + if (valueEnd > nbt.length) { + return nbt; + } + + byte[] value = worldName.getBytes(StandardCharsets.UTF_8); + if (value.length > 0xFFFF) { + return nbt; + } + + byte[] patched = new byte[nbt.length - oldLength + value.length]; + System.arraycopy(nbt, 0, patched, 0, lengthAt); + patched[lengthAt] = (byte) (value.length >> 8); + patched[lengthAt + 1] = (byte) value.length; + System.arraycopy(value, 0, patched, lengthAt + 2, value.length); + System.arraycopy(nbt, valueEnd, patched, lengthAt + 2 + value.length, nbt.length - valueEnd); + return patched; + } + + private static int indexOf(byte[] haystack, byte[] needle) { + for (int i = 0; i <= haystack.length - needle.length; i++) { + if (Arrays.equals(haystack, i, i + needle.length, needle, 0, needle.length)) { + return i; + } + } + return -1; + } + + private static byte[] gunzip(byte[] compressed) throws IOException { + try (InputStream in = new GZIPInputStream(new java.io.ByteArrayInputStream(compressed))) { + return in.readAllBytes(); + } + } + + private static byte[] gzip(byte[] raw) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(raw.length); + try (OutputStream out = new GZIPOutputStream(bytes)) { + out.write(raw); + } + return bytes.toByteArray(); + } +} 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..872083c4 --- /dev/null +++ b/buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/WorldExporterTest.java @@ -0,0 +1,145 @@ +/* + * 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.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.HashMap; +import java.util.Map; +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 { + + @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("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); + + Map entries = read(archive); + assertEquals("region-data", new String(entries.get("lobby/dimensions/minecraft/overworld/region/r.0.0.mca"))); + assertTrue(entries.containsKey("lobby/dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat")); + 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); + + 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")); + } + + 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; + } +} From daa39d53607f1647fccefcfb771fce6e0cd5be4c Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:01:18 +0200 Subject: [PATCH 02/11] fix: stop warning about head profiles Mojang does not know An offline-mode UUID, a renamed account or a world name that is not a player is a routine miss: the slot keeps its placeholder head. Log those at FINE without a stack trace, and keep the warning for unexpected failures. --- .../eintosti/buildsystem/menu/MenuItems.java | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 From dba33e189a22b08132fa5899cdca012987f2adcd Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:01:22 +0200 Subject: [PATCH 03/11] feat: add /worlds download for single-player world exports Since Paper 26.1 a world is a dimension of the main level and has no level.dat of its own, so its folder is not a save a client can open. /worlds download rebuilds the missing layout - the world becomes the save's overworld and gets a level.dat named after it - and serves the archive over a built-in HTTP server. Disabled by default. An archive is reachable only through a random 256-bit token that forms the whole URL path, so a request can neither name a file nor escape the download folder, and archives are deleted with their token on expiry, reload and shutdown. --- buildsystem-core/build.gradle.kts | 4 ++++ .../de/eintosti/buildsystem/BuildSystemPlugin.java | 4 ++++ .../java/de/eintosti/buildsystem/Services.java | 8 ++++++++ .../buildsystem/command/WorldsCommand.java | 3 +++ .../subcommand/worlds/DownloadSubCommand.java | 9 +++++---- .../command/subcommand/worlds/HelpSubCommand.java | 6 ++++++ .../command/subcommand/worlds/WorldsArgument.java | 1 + .../eintosti/buildsystem/config/ConfigService.java | 10 +++++++++- .../eintosti/buildsystem/config/PluginConfig.java | 14 +++++++++++++- .../de/eintosti/buildsystem/util/Permissions.java | 1 + .../world/download/WorldDownloadService.java | 4 ++-- .../buildsystem/world/download/WorldExporter.java | 4 +++- buildsystem-core/src/main/resources/config.yml | 11 +++++++++++ buildsystem-core/src/main/resources/messages.yml | 9 +++++++++ 14 files changed, 79 insertions(+), 9 deletions(-) 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 index 262f084f..df8c9e28 100644 --- 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 @@ -94,8 +94,7 @@ public void execute(Player player, String worldName, String[] args) { (url, throwable) -> { preparing.remove(playerId); if (throwable != null) { - logger.log( - Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable); + logger.log(Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable); messages.sendMessage(player, "worlds_download_failed", worldPlaceholder); return; } @@ -110,8 +109,10 @@ private void sendLink(Player player, BuildWorld buildWorld, String url) { String message = messages.getString( "worlds_download_finished", player, - Placeholders.of("%world%", buildWorld.getName()) - .add("%minutes%", String.valueOf(downloadService.getExpirationMinutes()))); + Placeholders.of() + .add("%world%", buildWorld.getName()) + .add("%minutes%", downloadService.getExpirationMinutes()) + .build()); TextComponent component = new TextComponent(TextComponent.fromLegacyText(message)); component.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); 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/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..6506de82 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,13 @@ 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))); + Set deletionBlacklist = config.getStringList("world.deletion-blacklist").stream() .map(name -> name.toLowerCase(Locale.ROOT)) .collect(Collectors.toSet()); @@ -213,7 +220,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..c97aa2ca 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,17 @@ 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) {} } public record Folder(boolean overridePermissions, boolean overrideProjects) {} 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/world/download/WorldDownloadService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java index 3aa32db4..ff3bb001 100644 --- 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 @@ -65,7 +65,6 @@ public final class WorldDownloadService { private static final int TOKEN_BYTES = 32; private static final int HTTP_THREADS = 2; private static final long PURGE_INTERVAL_TICKS = Duration.ofMinutes(1).toSeconds() * 20L; - private static final long SHUTDOWN_GRACE_SECONDS = 5L; private final ConfigService configService; private final TaskScheduler scheduler; @@ -181,7 +180,8 @@ public CompletableFuture prepare(BuildWorld buildWorld) { String worldName = buildWorld.getName(); String token = generateToken(); Path archive = new File(downloadFolder, token + ".zip").toPath(); - long expiresAt = System.currentTimeMillis() + Duration.ofMinutes(getExpirationMinutes()).toMillis(); + long expiresAt = System.currentTimeMillis() + + Duration.ofMinutes(getExpirationMinutes()).toMillis(); return CompletableFuture.supplyAsync( () -> { 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 index 8d7eb9ec..8316a6ef 100644 --- 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 @@ -17,6 +17,7 @@ */ package de.eintosti.buildsystem.world.download; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -165,6 +166,7 @@ private static byte[] withLevelName(byte[] nbt, String worldName) { if (lengthAt + 2 > nbt.length) { return nbt; } + int oldLength = ((nbt[lengthAt] & 0xFF) << 8) | (nbt[lengthAt + 1] & 0xFF); int valueEnd = lengthAt + 2 + oldLength; if (valueEnd > nbt.length) { @@ -195,7 +197,7 @@ private static int indexOf(byte[] haystack, byte[] needle) { } private static byte[] gunzip(byte[] compressed) throws IOException { - try (InputStream in = new GZIPInputStream(new java.io.ByteArrayInputStream(compressed))) { + try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(compressed))) { return in.readAllBytes(); } } diff --git a/buildsystem-core/src/main/resources/config.yml b/buildsystem-core/src/main/resources/config.yml index f30673fe..257ba765 100644 --- a/buildsystem-core/src/main/resources/config.yml +++ b/buildsystem-core/src/main/resources/config.yml @@ -110,6 +110,17 @@ 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 + 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..fdbc3202 100644 --- a/buildsystem-core/src/main/resources/messages.yml +++ b/buildsystem-core/src/main/resources/messages.yml @@ -203,6 +203,14 @@ 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_failed: "%prefix% &cUnable to prepare %world% for download. See the console for details." +worlds_download_finished: "%prefix% &7&b%world% &7is ready: &a&nClick here to download&7. &8(&7Expires in &f%minutes% minutes&8)" + worlds_edit_usage: "%prefix% &7Usage: &b/worlds edit " worlds_edit_unknown_world: "%prefix% &cUnknown world." @@ -247,6 +255,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." From 8f4fe62b0271a02638f64c2f9e321f60ee57ade5 Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:11:17 +0200 Subject: [PATCH 04/11] feat: harden the world download server Adds the limits the first cut left out: a link is pinned to the client that uses it first, so a forwarded link is useless; requests are rate limited per address and concurrent transfers capped, so one host cannot saturate the endpoint; exports are bounded by a per-archive size and a shared storage budget, so a large world cannot fill the disk. Exports also stop carrying data that is not theirs. The main level holds every player's inventory, position and statistics - none of it ships now - and the exported level.dat is rewritten through the NBT library the plugin already depends on to drop the server's brand, version and server-side datapacks. level.dat_old is excluded for the same reason: it is an untouched copy of what the rewrite removes. TLS stays out of the plugin. A keystore would need managing and reloading on every certificate renewal; a proxy in front of the port does it better, so the server logs a warning while its URL is not https. --- .../subcommand/worlds/DownloadSubCommand.java | 30 ++- .../buildsystem/config/ConfigService.java | 5 +- .../buildsystem/config/PluginConfig.java | 9 +- .../world/download/DownloadRegistry.java | 146 ++++++++++++ .../world/download/RequestRateLimiter.java | 75 +++++++ .../world/download/WorldDownloadService.java | 121 ++++++---- .../world/download/WorldExporter.java | 210 ++++++++++++------ .../src/main/resources/config.yml | 4 + .../src/main/resources/messages.yml | 2 + .../world/download/DownloadRegistryTest.java | 105 +++++++++ .../world/download/WorldExporterTest.java | 6 +- 11 files changed, 602 insertions(+), 111 deletions(-) create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/DownloadRegistry.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/RequestRateLimiter.java create mode 100644 buildsystem-core/src/test/java/de/eintosti/buildsystem/world/download/DownloadRegistryTest.java 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 index df8c9e28..27644f0f 100644 --- 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 @@ -27,9 +27,12 @@ import de.eintosti.buildsystem.util.TaskScheduler; import de.eintosti.buildsystem.world.WorldServiceImpl; 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.logging.Level; import java.util.logging.Logger; @@ -94,8 +97,7 @@ public void execute(Player player, String worldName, String[] args) { (url, throwable) -> { preparing.remove(playerId); if (throwable != null) { - logger.log(Level.SEVERE, "Failed to export world " + buildWorld.getName(), throwable); - messages.sendMessage(player, "worlds_download_failed", worldPlaceholder); + sendFailure(player, buildWorld, worldPlaceholder, throwable); return; } if (player.isOnline()) { @@ -105,6 +107,30 @@ public void execute(Player player, String worldName, String[] args) { scheduler.mainThread()); } + /** + * 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); + } + } + } + private void sendLink(Player player, BuildWorld buildWorld, String url) { String message = messages.getString( "worlds_download_finished", 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 6506de82..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 @@ -203,7 +203,10 @@ private static PluginConfig.World parseWorld(FileConfiguration config, Logger lo 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.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)) 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 c97aa2ca..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 @@ -191,7 +191,14 @@ public record AutoBackup(boolean enabled, boolean onlyActiveWorlds, int interval * @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) {} + 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/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/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 index ff3bb001..8af503a1 100644 --- 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 @@ -31,15 +31,13 @@ import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; -import java.security.SecureRandom; import java.time.Duration; -import java.util.HexFormat; import java.util.List; -import java.util.Map; +import java.util.Locale; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; 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; @@ -55,15 +53,16 @@ * *

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. Archives and their tokens are dropped together — on expiry, on reload and on - * shutdown — so an export never outlives its link. + * {@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 TOKEN_BYTES = 32; - private static final int HTTP_THREADS = 2; + 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; @@ -71,11 +70,13 @@ public final class WorldDownloadService { private final Logger logger; private final File downloadFolder; - private final SecureRandom random = new SecureRandom(); - private final Map downloads = new ConcurrentHashMap<>(); + 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) { @@ -101,7 +102,8 @@ public void start() { return; } - ExecutorService executor = Executors.newFixedThreadPool(HTTP_THREADS, threadFactory()); + 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); @@ -109,14 +111,16 @@ public void start() { 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::purgeExpired, PURGE_INTERVAL_TICKS, PURGE_INTERVAL_TICKS); + this.purgeTask = scheduler.runTimer(this::purge, PURGE_INTERVAL_TICKS, PURGE_INTERVAL_TICKS); logger.info("World downloads are available on port " + config.port()); + warnAboutPlaintext(config); } /** @@ -135,7 +139,9 @@ public void stop() { httpExecutor.shutdownNow(); httpExecutor = null; } - downloads.clear(); + transferSlots = null; + registry.clear(); + rateLimiter.clear(); clearDownloadFolder(); } @@ -170,6 +176,12 @@ public CompletableFuture prepare(BuildWorld buildWorld) { 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()) { @@ -178,37 +190,71 @@ public CompletableFuture prepare(BuildWorld buildWorld) { File defaultLevelFolder = worlds.getFirst().getWorldFolder(); String worldName = buildWorld.getName(); - String token = generateToken(); - Path archive = new File(downloadFolder, token + ".zip").toPath(); + long maxArchiveBytes = Math.min(megabytes(config.maxSizeMb()), storageBudget - registry.totalBytes()); long expiresAt = System.currentTimeMillis() - + Duration.ofMinutes(getExpirationMinutes()).toMillis(); + + 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); + WorldExporter.export(worldName, worldFolder, defaultLevelFolder, archive, maxArchiveBytes); } catch (IOException e) { throw new UncheckedIOException(e); } - downloads.put(token, new Download(archive, WorldExporter.fileName(worldName) + ".zip", expiresAt)); + 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; } - Download download = downloads.get(token(exchange)); - if (download == null || download.isExpired() || !Files.isRegularFile(download.file())) { + 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() + "\""); @@ -219,9 +265,8 @@ private void handle(HttpExchange exchange) throws IOException { try (OutputStream out = exchange.getResponseBody()) { Files.copy(download.file(), out); } - } 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); + } finally { + slots.release(); } } @@ -234,14 +279,13 @@ private String token(HttpExchange exchange) { return path.length() > CONTEXT_PATH.length() ? path.substring(CONTEXT_PATH.length()) : ""; } - private void purgeExpired() { - downloads.values().removeIf(download -> { - if (!download.isExpired()) { - return false; - } - delete(download.file()); - return true; - }); + 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) { @@ -250,10 +294,8 @@ private String url(String token) { return trimmed + CONTEXT_PATH + token; } - private String generateToken() { - byte[] bytes = new byte[TOKEN_BYTES]; - random.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); + private static long megabytes(int megabytes) { + return megabytes * 1024L * 1024L; } private PluginConfig.World.Download config() { @@ -292,10 +334,13 @@ private static ThreadFactory threadFactory() { }; } - private record Download(Path file, String fileName, long expiresAt) { + /** + * Thrown when the live archives already fill the configured storage budget. + */ + public static final class StorageFullException extends IOException { - boolean isExpired() { - return System.currentTimeMillis() > expiresAt; + 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 index 8316a6ef..fb32be8c 100644 --- 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 @@ -17,26 +17,28 @@ */ package de.eintosti.buildsystem.world.download; -import java.io.ByteArrayInputStream; +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.InputStream; import java.io.OutputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Stream; -import java.util.zip.GZIPInputStream; 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. @@ -50,14 +52,32 @@ @NullMarked public final class WorldExporter { - private static final Set EXCLUDED_FILES = Set.of("session.lock", "uid.dat", "paper-world.yml"); + /** + * 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"); private static final Pattern UNSAFE_NAME_CHARACTERS = Pattern.compile("[^A-Za-z0-9._-]"); /** - * The {@code TAG_String("LevelName")} header: tag id, then the 2-byte length and bytes of the tag name. + * 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 byte[] LEVEL_NAME_TAG = {8, 0, 9, 'L', 'e', 'v', 'e', 'l', 'N', 'a', 'm', 'e'}; + 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() {} @@ -68,9 +88,11 @@ private WorldExporter() {} * @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 + * @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) + public static void export(String worldName, File worldFolder, File defaultLevelFolder, Path target, long maxBytes) throws IOException { Path source = worldFolder.toPath(); if (!Files.isDirectory(source)) { @@ -80,13 +102,15 @@ public static void export(String worldName, File worldFolder, File defaultLevelF String rootDirectory = fileName(worldName); Files.createDirectories(target.getParent()); - try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(target))) { + try (CountingOutputStream counter = new CountingOutputStream(Files.newOutputStream(target), maxBytes); + ZipOutputStream zip = new ZipOutputStream(counter)) { if (Files.isRegularFile(source.resolve("level.dat"))) { - // Already a level folder: the pre-26.1 flat layout, or a world that is itself the main level. Nested - // dimensions belong to other worlds and are left behind. + // 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, rootDirectory + "/", true); } else { - writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder, worldName)); + writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder.toPath(), worldName)); copyTree(zip, source, rootDirectory + "/dimensions/minecraft/overworld/", false); } } catch (IOException e) { @@ -103,13 +127,13 @@ public static String fileName(String name) { return sanitized.isBlank() ? "world" : sanitized; } - private static void copyTree(ZipOutputStream zip, Path root, String prefix, boolean skipNestedDimensions) + private static void copyTree(ZipOutputStream zip, Path root, String prefix, boolean isLevelFolder) throws IOException { try (Stream walk = Files.walk(root)) { List files = walk.filter(Files::isRegularFile).toList(); for (Path file : files) { Path relative = root.relativize(file); - if (isExcluded(relative, skipNestedDimensions)) { + if (isExcluded(relative, isLevelFolder)) { continue; } zip.putNextEntry(new ZipEntry(prefix + relative.toString().replace(File.separatorChar, '/'))); @@ -119,14 +143,32 @@ private static void copyTree(ZipOutputStream zip, Path root, String prefix, bool } } - private static boolean isExcluded(Path relative, boolean skipNestedDimensions) { + /** + * 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 (!skipNestedDimensions || relative.getNameCount() < 3) { + if (!isLevelFolder) { return false; } - return relative.getName(0).toString().equals("dimensions") + 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)); } @@ -138,75 +180,109 @@ private static void writeEntry(ZipOutputStream zip, String entryName, byte[] con } /** - * Reads the main level's {@code level.dat} and renames the save to the exported world, so a player who exports - * several worlds does not end up with a list of identically named saves. + * 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(File defaultLevelFolder, String worldName) throws IOException { - Path levelDat = defaultLevelFolder.toPath().resolve("level.dat"); + private static byte[] levelDat(Path levelFolder, String worldName) throws IOException { + Path levelDat = levelFolder.resolve("level.dat"); if (!Files.isRegularFile(levelDat)) { - throw new IOException("Main level has no level.dat to copy: " + levelDat); + throw new IOException("No level.dat to export: " + levelDat); + } + + 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 gzip(withLevelName(gunzip(Files.readAllBytes(levelDat)), worldName)); + return bytes.toByteArray(); } /** - * Replaces the value of the {@code LevelName} tag in uncompressed NBT, returning the data unchanged when the tag - * is not found. - * - *

ponytail: a byte splice rather than an NBT parser — the tag header is self-delimiting and this is the only - * field the export rewrites. Parse properly if a second field ever needs changing. + * 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 byte[] withLevelName(byte[] nbt, String worldName) { - int header = indexOf(nbt, LEVEL_NAME_TAG); - if (header < 0) { - return nbt; + 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); } + } - int lengthAt = header + LEVEL_NAME_TAG.length; - if (lengthAt + 2 > nbt.length) { - return nbt; + /** + * 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"); } + } - int oldLength = ((nbt[lengthAt] & 0xFF) << 8) | (nbt[lengthAt + 1] & 0xFF); - int valueEnd = lengthAt + 2 + oldLength; - if (valueEnd > nbt.length) { - return nbt; + /** + * 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; } - byte[] value = worldName.getBytes(StandardCharsets.UTF_8); - if (value.length > 0xFFFF) { - return nbt; + @Override + public void write(int b) throws IOException { + count(1); + delegate.write(b); } - byte[] patched = new byte[nbt.length - oldLength + value.length]; - System.arraycopy(nbt, 0, patched, 0, lengthAt); - patched[lengthAt] = (byte) (value.length >> 8); - patched[lengthAt + 1] = (byte) value.length; - System.arraycopy(value, 0, patched, lengthAt + 2, value.length); - System.arraycopy(nbt, valueEnd, patched, lengthAt + 2 + value.length, nbt.length - valueEnd); - return patched; - } + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + count(length); + delegate.write(bytes, offset, length); + } - private static int indexOf(byte[] haystack, byte[] needle) { - for (int i = 0; i <= haystack.length - needle.length; i++) { - if (Arrays.equals(haystack, i, i + needle.length, needle, 0, needle.length)) { - return i; - } + @Override + public void flush() throws IOException { + delegate.flush(); } - return -1; - } - private static byte[] gunzip(byte[] compressed) throws IOException { - try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(compressed))) { - return in.readAllBytes(); + @Override + public void close() throws IOException { + delegate.close(); } - } - private static byte[] gzip(byte[] raw) throws IOException { - ByteArrayOutputStream bytes = new ByteArrayOutputStream(raw.length); - try (OutputStream out = new GZIPOutputStream(bytes)) { - out.write(raw); + private void count(int bytes) throws IOException { + written += bytes; + if (written > maxBytes) { + throw new WorldTooLargeException(maxBytes); + } } - return bytes.toByteArray(); } } diff --git a/buildsystem-core/src/main/resources/config.yml b/buildsystem-core/src/main/resources/config.yml index 257ba765..b5ae91e4 100644 --- a/buildsystem-core/src/main/resources/config.yml +++ b/buildsystem-core/src/main/resources/config.yml @@ -120,6 +120,10 @@ world: # 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 diff --git a/buildsystem-core/src/main/resources/messages.yml b/buildsystem-core/src/main/resources/messages.yml index fdbc3202..6338d11b 100644 --- a/buildsystem-core/src/main/resources/messages.yml +++ b/buildsystem-core/src/main/resources/messages.yml @@ -209,6 +209,8 @@ worlds_download_disabled: "%prefix% &cWorld downloads are disabled on this serve worlds_download_in_progress: "%prefix% &cYou are already preparing a download." worlds_download_preparing: "%prefix% &7Preparing &b%world% &7for download..." 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% &7&b%world% &7is ready: &a&nClick here to download&7. &8(&7Expires in &f%minutes% minutes&8)" worlds_edit_usage: "%prefix% &7Usage: &b/worlds edit " 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 index 872083c4..528dc6ab 100644 --- 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 @@ -46,6 +46,8 @@ @NullMarked class WorldExporterTest { + private static final long MAX_BYTES = 64L * 1024 * 1024; + @TempDir Path tempDir; @@ -61,7 +63,7 @@ void exportsDimensionWorldAsSinglePlayerSave() throws IOException { File level = levelFolder("MainLevel"); Path archive = tempDir.resolve("out.zip"); - WorldExporter.export("lobby", dimension.toFile(), level, archive); + WorldExporter.export("lobby", dimension.toFile(), level, archive, MAX_BYTES); Map entries = read(archive); assertEquals("region-data", new String(entries.get("lobby/dimensions/minecraft/overworld/region/r.0.0.mca"))); @@ -83,7 +85,7 @@ void keepsFlatWorldLayoutAndDropsForeignDimensions() throws IOException { 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); + WorldExporter.export("legacy", world.toFile(), levelFolder("MainLevel"), archive, MAX_BYTES); Map entries = read(archive); assertTrue(entries.containsKey("legacy/region/r.0.0.mca")); From 48f8a0e079f74935581feb753c79e6a7c9851e04 Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:15:25 +0200 Subject: [PATCH 05/11] fix: find level.dat when Paper reports the overworld dimension folder Bukkit#getWorldFolder for the main level is not always the level root: on some setups it is /dimensions/minecraft/overworld, and the export then looked for level.dat inside it and failed. Search upwards from the reported folder instead, far enough to cover the dimension shape and no further. --- .../world/download/WorldExporter.java | 35 +++++++++++++++++-- .../world/download/WorldExporterTest.java | 18 ++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) 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 index fb32be8c..26d69736 100644 --- 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 @@ -60,6 +60,12 @@ public final class WorldExporter { 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; + private static final Pattern UNSAFE_NAME_CHARACTERS = Pattern.compile("[^A-Za-z0-9._-]"); /** @@ -184,9 +190,9 @@ private static void writeEntry(ZipOutputStream zip, String entryName, byte[] con * 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 = levelFolder.resolve("level.dat"); - if (!Files.isRegularFile(levelDat)) { - throw new IOException("No level.dat to export: " + levelDat); + Path levelDat = findLevelDat(levelFolder); + if (levelDat == null) { + throw new IOException("No level.dat to export at or above: " + levelFolder); } Nbt nbt = new Nbt(); @@ -208,6 +214,29 @@ private static byte[] levelDat(Path levelFolder, String worldName) throws IOExce 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. 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 index 528dc6ab..bf0d9c69 100644 --- 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 @@ -93,6 +93,24 @@ void keepsFlatWorldLayoutAndDropsForeignDimensions() throws IOException { 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); + + assertEquals("lobby", levelName(read(archive).get("lobby/level.dat"))); + } + private File levelFolder(String levelName) throws IOException { Path level = tempDir.resolve("level-" + levelName); Files.createDirectories(level); From 5d88748f8b1f415d298c711ffcc84b5ee509445c Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:20:22 +0200 Subject: [PATCH 06/11] feat: show an animated progress bar while a world is packed The export walks the world twice - once to size it, once to pack it - so the action bar can show real progress rather than an indeterminate spinner. A highlight sweeps through the filled part of the bar and a spinner turns beside it, both driven by the redraw counter, so an export sitting on one large region file still reads as working. The spinner is ASCII: the client's default font renders it everywhere, which is not true of the braille glyphs usually used for this. The whole line is a message key, so servers can restyle or shorten it. --- .../subcommand/worlds/DownloadSubCommand.java | 64 +++++++++++++- .../world/download/ExportProgressBar.java | 83 +++++++++++++++++++ .../world/download/WorldDownloadService.java | 6 +- .../world/download/WorldExporter.java | 78 +++++++++++++---- .../src/main/resources/messages.yml | 1 + .../world/download/WorldExporterTest.java | 51 +++++++++++- 6 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/ExportProgressBar.java 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 index 27644f0f..e81b2b54 100644 --- 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 @@ -26,6 +26,7 @@ import de.eintosti.buildsystem.i18n.Placeholders; import de.eintosti.buildsystem.util.TaskScheduler; 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; @@ -34,19 +35,29 @@ 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 net.md_5.bungee.api.ChatMessageType; 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.World; 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; + private final WorldDownloadService downloadService; private final TaskScheduler scheduler; private final Logger logger; @@ -91,11 +102,20 @@ public void execute(Player player, String worldName, String[] args) { messages.sendMessage(player, "worlds_download_preparing", worldPlaceholder); buildWorld.getWorld().ifPresent(World::save); + AtomicLong packedBytes = new AtomicLong(); + AtomicLong totalBytes = new AtomicLong(); + BukkitTask animation = startProgressAnimation(player, buildWorld, packedBytes, totalBytes); + downloadService - .prepare(buildWorld) + .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; @@ -107,6 +127,48 @@ public void execute(Player player, String worldName, String[] args) { 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.percent(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. 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..353be7b7 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/ExportProgressBar.java @@ -0,0 +1,83 @@ +/* + * 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: the client's default font renders these everywhere, which is not true of the + * braille and circle glyphs usually used for spinners. + */ + 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); + } + + 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/WorldDownloadService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/download/WorldDownloadService.java index 8af503a1..b9a6d707 100644 --- 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 @@ -169,9 +169,10 @@ public int getExpirationMinutes() { * 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) { + public CompletableFuture prepare(BuildWorld buildWorld, WorldExporter.ExportProgress progress) { if (server == null) { return CompletableFuture.failedFuture(new IllegalStateException("World downloads are disabled")); } @@ -198,7 +199,8 @@ public CompletableFuture prepare(BuildWorld buildWorld) { return CompletableFuture.supplyAsync( () -> { try { - WorldExporter.export(worldName, worldFolder, defaultLevelFolder, archive, maxArchiveBytes); + WorldExporter.export( + worldName, worldFolder, defaultLevelFolder, archive, maxArchiveBytes, progress); } catch (IOException e) { throw new UncheckedIOException(e); } 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 index 26d69736..7a384cc5 100644 --- 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 @@ -95,10 +95,17 @@ private WorldExporter() {} * @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) + 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)) { @@ -107,17 +114,20 @@ public static void export(String worldName, File worldFolder, File defaultLevelF 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 (Files.isRegularFile(source.resolve("level.dat"))) { + 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, rootDirectory + "/", true); + copyTree(zip, source, rootDirectory + "/", files, progress); } else { writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder.toPath(), worldName)); - copyTree(zip, source, rootDirectory + "/dimensions/minecraft/overworld/", false); + copyTree(zip, source, rootDirectory + "/dimensions/minecraft/overworld/", files, progress); } } catch (IOException e) { Files.deleteIfExists(target); @@ -125,6 +135,21 @@ public static void export(String worldName, File worldFolder, File defaultLevelF } } + /** + * 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} */ @@ -133,19 +158,42 @@ public static String fileName(String name) { return sanitized.isBlank() ? "world" : sanitized; } - private static void copyTree(ZipOutputStream zip, Path root, String prefix, boolean isLevelFolder) + private static void copyTree( + ZipOutputStream zip, Path root, String prefix, List files, ExportProgress progress) throws IOException { + long total = totalBytes(files); + long packed = 0L; + for (Path file : files) { + Path relative = root.relativize(file); + zip.putNextEntry(new ZipEntry(prefix + relative.toString().replace(File.separatorChar, '/'))); + Files.copy(file, zip); + zip.closeEntry(); + + packed += sizeOf(file); + progress.update(packed, total); + } + } + + /** + * 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)) { - List files = walk.filter(Files::isRegularFile).toList(); - for (Path file : files) { - Path relative = root.relativize(file); - if (isExcluded(relative, isLevelFolder)) { - continue; - } - zip.putNextEntry(new ZipEntry(prefix + relative.toString().replace(File.separatorChar, '/'))); - Files.copy(file, zip); - zip.closeEntry(); - } + 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; } } diff --git a/buildsystem-core/src/main/resources/messages.yml b/buildsystem-core/src/main/resources/messages.yml index 6338d11b..3bf59076 100644 --- a/buildsystem-core/src/main/resources/messages.yml +++ b/buildsystem-core/src/main/resources/messages.yml @@ -208,6 +208,7 @@ 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." 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 index bf0d9c69..8c8a106c 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -29,8 +30,11 @@ 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; @@ -63,7 +67,7 @@ void exportsDimensionWorldAsSinglePlayerSave() throws IOException { File level = levelFolder("MainLevel"); Path archive = tempDir.resolve("out.zip"); - WorldExporter.export("lobby", dimension.toFile(), level, archive, MAX_BYTES); + 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"))); @@ -85,7 +89,7 @@ void keepsFlatWorldLayoutAndDropsForeignDimensions() throws IOException { 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.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")); @@ -106,11 +110,52 @@ void findsTheLevelDatWhenPaperReportsTheOverworldDimensionFolder() throws IOExce 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.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)); + } + private File levelFolder(String levelName) throws IOException { Path level = tempDir.resolve("level-" + levelName); Files.createDirectories(level); From 526bef52919f484ae95c7f7e41a20889674f967b Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:21:40 +0200 Subject: [PATCH 07/11] fix: restrict the download link to its button segment The whole finished line carried the click and hover, so clicking anywhere on it opened the browser. The message now marks the button with %button% and only that component gets the events; a message customized without the placeholder still gets the button appended rather than losing its link. --- .../subcommand/worlds/DownloadSubCommand.java | 42 +++++++++++++------ .../src/main/resources/messages.yml | 3 +- .../world/download/WorldExporterTest.java | 19 +++++++-- 3 files changed, 48 insertions(+), 16 deletions(-) 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 index e81b2b54..b159c81b 100644 --- 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 @@ -39,6 +39,7 @@ 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.ClickEvent; import net.md_5.bungee.api.chat.HoverEvent; @@ -58,6 +59,11 @@ public class DownloadSubCommand extends AbstractSubCommand { */ 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; @@ -193,19 +199,31 @@ private void sendFailure(Player player, BuildWorld buildWorld, Placeholders worl } } + /** + * 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 message customized without the placeholder still gets the button, appended. + */ private void sendLink(Player player, BuildWorld buildWorld, String url) { - String message = messages.getString( - "worlds_download_finished", - player, - Placeholders.of() - .add("%world%", buildWorld.getName()) - .add("%minutes%", downloadService.getExpirationMinutes()) - .build()); - - TextComponent component = new TextComponent(TextComponent.fromLegacyText(message)); - component.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); - component.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url))); - player.spigot().sendMessage(component); + Placeholders placeholders = Placeholders.of() + .add("%world%", buildWorld.getName()) + .add("%minutes%", downloadService.getExpirationMinutes()) + .build(); + String[] parts = messages.getString("worlds_download_finished", player, placeholders) + .split(BUTTON_PLACEHOLDER, 2); + + TextComponent button = new TextComponent( + TextComponent.fromLegacyText(messages.getString("worlds_download_button", player, placeholders))); + button.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); + button.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url))); + + TextComponent message = new TextComponent(); + message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[0]))); + message.addExtra(button); + if (parts.length > 1) { + message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[1]))); + } + + player.spigot().sendMessage(message); XSound.ENTITY_PLAYER_LEVELUP.play(player); } diff --git a/buildsystem-core/src/main/resources/messages.yml b/buildsystem-core/src/main/resources/messages.yml index 3bf59076..3f26ccb3 100644 --- a/buildsystem-core/src/main/resources/messages.yml +++ b/buildsystem-core/src/main/resources/messages.yml @@ -212,7 +212,8 @@ worlds_download_progress: "&8[%bar%&8] &f%percent%% &7Packing &b%world% &7%spinn 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% &7&b%world% &7is ready: &a&nClick here to download&7. &8(&7Expires in &f%minutes% minutes&8)" +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." 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 index 8c8a106c..11292243 100644 --- 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 @@ -67,7 +67,8 @@ void exportsDimensionWorldAsSinglePlayerSave() throws IOException { File level = levelFolder("MainLevel"); Path archive = tempDir.resolve("out.zip"); - WorldExporter.export("lobby", dimension.toFile(), level, archive, MAX_BYTES, WorldExporter.ExportProgress.IGNORED); + 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"))); @@ -89,7 +90,13 @@ void keepsFlatWorldLayoutAndDropsForeignDimensions() throws IOException { 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); + 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")); @@ -110,7 +117,13 @@ void findsTheLevelDatWhenPaperReportsTheOverworldDimensionFolder() throws IOExce 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); + WorldExporter.export( + "lobby", + dimension.toFile(), + overworld.toFile(), + archive, + MAX_BYTES, + WorldExporter.ExportProgress.IGNORED); assertEquals("lobby", levelName(read(archive).get("lobby/level.dat"))); } From d121841bb4a7faec7b51d0b30b7e7c8dec4f378f Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:26:24 +0200 Subject: [PATCH 08/11] fix: stop printing the download button twice, and steady the spinner Two display bugs from the last two commits. A messages.yml written before %button% existed keeps its old worlds_download_finished line, because loading only fills in missing keys. Appending the button to such a message printed the words a second time; the whole line becomes clickable instead, as it was before. The spinner also shifted the message. The default font is variable-width and the action bar is centered, so the two-pixel | moved everything sideways on every fourth frame. The rotation now uses only six-pixel frames, and the percentage is padded to a fixed width for the same reason. --- .../subcommand/worlds/DownloadSubCommand.java | 36 ++++++++++++------- .../world/download/ExportProgressBar.java | 18 ++++++++-- .../world/download/WorldExporterTest.java | 7 ++++ 3 files changed, 46 insertions(+), 15 deletions(-) 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 index b159c81b..2f73e302 100644 --- 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 @@ -41,6 +41,7 @@ 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; @@ -157,7 +158,7 @@ private BukkitTask startProgressAnimation( Placeholders.of() .add("%world%", buildWorld.getName()) .add("%bar%", ExportProgressBar.bar(fraction, currentFrame)) - .add("%percent%", ExportProgressBar.percent(fraction)) + .add("%percent%", ExportProgressBar.percentText(fraction)) .add("%spinner%", ExportProgressBar.spinner(currentFrame)) .build())); }, @@ -201,32 +202,43 @@ private void sendFailure(Player player, BuildWorld buildWorld, Placeholders worl /** * 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 message customized without the placeholder still gets the button, appended. + * 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[] parts = messages.getString("worlds_download_finished", player, placeholders) - .split(BUTTON_PLACEHOLDER, 2); + String text = messages.getString("worlds_download_finished", player, placeholders); - TextComponent button = new TextComponent( - TextComponent.fromLegacyText(messages.getString("worlds_download_button", player, placeholders))); - button.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); - button.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(url))); + 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(button); - if (parts.length > 1) { - message.addExtra(new TextComponent(TextComponent.fromLegacyText(parts[1]))); - } + 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) { 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 index 353be7b7..8548741f 100644 --- 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 @@ -37,10 +37,14 @@ public final class ExportProgressBar { private static final String EMPTY_COLOR = "&8"; /** - * Frames of the spinner. Plain ASCII: the client's default font renders these everywhere, which is not true of the - * braille and circle glyphs usually used for spinners. + * 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 static final char[] SPINNER = {'/', '-', '\\'}; private ExportProgressBar() {} @@ -74,6 +78,14 @@ 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; 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 index 11292243..0a8f5e9d 100644 --- 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 @@ -167,6 +167,13 @@ void barFillsAndAnimatesWithinTheFilledPart() { 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 { From 6af0942bfe199b182175818643b9cb37ff48d76a Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 12:54:00 +0200 Subject: [PATCH 09/11] fix: put level-scoped world data where the client reads it An exported world would not load: the client reported datapack errors, then invalid save data in Safe Mode. Paper keeps a per-world copy of the level-scoped files inside each dimension folder, because every Bukkit world is a level of its own. A single-player save keeps one set in the save root's data/minecraft and only raids, world_border and chunk_tickets inside the dimension. The export copied the dimension folder wholesale, so world_gen_settings.dat never reached the root and the client could not build the level. Files are now routed by scope. Verified against the layout documented on the wiki for 26.1+, and against a real dimension world from a 26.2 server. --- .../world/download/WorldExporter.java | 43 ++++++++++++++++--- .../world/download/WorldExporterTest.java | 11 ++++- 2 files changed, 48 insertions(+), 6 deletions(-) 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 index 7a384cc5..3e295fde 100644 --- 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 @@ -32,6 +32,7 @@ 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; @@ -66,6 +67,17 @@ public final class WorldExporter { */ 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._-]"); /** @@ -124,10 +136,10 @@ public static void export( // 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, rootDirectory + "/", files, progress); + copyTree(zip, source, files, progress, relative -> rootDirectory + "/" + slashed(relative)); } else { writeEntry(zip, rootDirectory + "/level.dat", levelDat(defaultLevelFolder.toPath(), worldName)); - copyTree(zip, source, rootDirectory + "/dimensions/minecraft/overworld/", files, progress); + copyTree(zip, source, files, progress, relative -> dimensionEntry(rootDirectory, relative)); } } catch (IOException e) { Files.deleteIfExists(target); @@ -159,13 +171,16 @@ public static String fileName(String name) { } private static void copyTree( - ZipOutputStream zip, Path root, String prefix, List files, ExportProgress progress) + ZipOutputStream zip, + Path root, + List files, + ExportProgress progress, + Function entryNamer) throws IOException { long total = totalBytes(files); long packed = 0L; for (Path file : files) { - Path relative = root.relativize(file); - zip.putNextEntry(new ZipEntry(prefix + relative.toString().replace(File.separatorChar, '/'))); + zip.putNextEntry(new ZipEntry(entryNamer.apply(root.relativize(file)))); Files.copy(file, zip); zip.closeEntry(); @@ -174,6 +189,24 @@ private static void copyTree( } } + /** + * 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. */ 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 index 0a8f5e9d..4dd0feb0 100644 --- 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 @@ -62,6 +62,8 @@ void exportsDimensionWorldAsSinglePlayerSave() throws IOException { 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"); @@ -72,7 +74,14 @@ void exportsDimensionWorldAsSinglePlayerSave() throws IOException { Map entries = read(archive); assertEquals("region-data", new String(entries.get("lobby/dimensions/minecraft/overworld/region/r.0.0.mca"))); - assertTrue(entries.containsKey("lobby/dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat")); + // 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")); From 91880f2d3e31250853bc61c289db969c3b64eaef Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 13:01:08 +0200 Subject: [PATCH 10/11] fix: wait for chunks to be written before archiving a world World#save returns while Paper's chunk writer is still running, so both a backup and a download could archive half-written region files - the bug reported as chunks missing from a downloaded world. Paper's save(boolean flush) waits for the writer. It is not in the Spigot API this plugin compiles against, so it is resolved reflectively and falls back to the plain save, which is already synchronous on Spigot. No Paper class is referenced at compile time. --- .../subcommand/worlds/DownloadSubCommand.java | 4 +- .../eintosti/buildsystem/util/WorldFlush.java | 62 +++++++++++++++++++ .../world/backup/BackupProfileImpl.java | 7 ++- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/WorldFlush.java 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 index 2f73e302..5e9c10db 100644 --- 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 @@ -25,6 +25,7 @@ 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; @@ -46,7 +47,6 @@ 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.World; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; import org.jspecify.annotations.NullMarked; @@ -107,7 +107,7 @@ public void execute(Player player, String worldName, String[] args) { Placeholders worldPlaceholder = Placeholders.of("%world%", buildWorld.getName()); messages.sendMessage(player, "worlds_download_preparing", worldPlaceholder); - buildWorld.getWorld().ifPresent(World::save); + buildWorld.getWorld().ifPresent(WorldFlush::saveAndFlush); AtomicLong packedBytes = new AtomicLong(); AtomicLong totalBytes = new AtomicLong(); 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..6bb27f8a 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; @@ -50,7 +51,6 @@ import net.lingala.zip4j.model.FileHeader; import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.jspecify.annotations.NullMarked; @@ -106,12 +106,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()); From bf7d34d3de344de8652b4beec3fa301ef549174b Mon Sep 17 00:00:00 2001 From: Thomas Meaney Date: Fri, 31 Jul 2026 13:01:40 +0200 Subject: [PATCH 11/11] fix: restore the World import dropped in the previous commit The previous commit removed the import along with the World::save reference, but three other call sites still use the type, so the module did not compile. --- .../de/eintosti/buildsystem/world/backup/BackupProfileImpl.java | 1 + 1 file changed, 1 insertion(+) 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 6bb27f8a..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 @@ -51,6 +51,7 @@ import net.lingala.zip4j.model.FileHeader; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.jspecify.annotations.NullMarked;