From f25402b668dec925a8f2ef16a5845aa43a4f5c60 Mon Sep 17 00:00:00 2001 From: yqs112358 <37969157+yqs112358@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:46:58 +0800 Subject: [PATCH] Client upload images to the server --- build.gradle | 37 +- .../image2map/client/Image2MapClient.java | 221 +++++++++ .../image2map/client/UploadConfirmScreen.java | 38 ++ .../client/UploadProgressScreen.java | 39 ++ .../java/space/essem/image2map/Image2Map.java | 386 +--------------- .../space/essem/image2map/ImageCommands.java | 83 ++++ .../image2map/config/ConfigMigration.java | 21 + .../image2map/config/Image2MapConfig.java | 107 +++-- .../space/essem/image2map/gui/PreviewGui.java | 77 ++-- .../essem/image2map/image/ImageFetcher.java | 80 ++++ .../essem/image2map/image/ImageSafety.java | 130 ++++++ .../image2map/network/UploadPayloads.java | 102 ++++ .../essem/image2map/renderer/MapRenderer.java | 5 +- .../image2map/upload/ImageTaskState.java | 48 ++ .../image2map/upload/ServerImageTasks.java | 435 ++++++++++++++++++ .../essem/image2map/upload/UploadBuffer.java | 34 ++ .../assets/image2map/lang/en_us.json | 10 + .../assets/image2map/lang/zh_cn.json | 10 + src/main/resources/fabric.mod.json | 3 + .../image2map/SecurityRegressionTest.java | 385 ++++++++++++++++ 20 files changed, 1804 insertions(+), 447 deletions(-) create mode 100644 src/client/java/space/essem/image2map/client/Image2MapClient.java create mode 100644 src/client/java/space/essem/image2map/client/UploadConfirmScreen.java create mode 100644 src/client/java/space/essem/image2map/client/UploadProgressScreen.java create mode 100644 src/main/java/space/essem/image2map/ImageCommands.java create mode 100644 src/main/java/space/essem/image2map/config/ConfigMigration.java create mode 100644 src/main/java/space/essem/image2map/image/ImageFetcher.java create mode 100644 src/main/java/space/essem/image2map/image/ImageSafety.java create mode 100644 src/main/java/space/essem/image2map/network/UploadPayloads.java create mode 100644 src/main/java/space/essem/image2map/upload/ImageTaskState.java create mode 100644 src/main/java/space/essem/image2map/upload/ServerImageTasks.java create mode 100644 src/main/java/space/essem/image2map/upload/UploadBuffer.java create mode 100644 src/main/resources/assets/image2map/lang/en_us.json create mode 100644 src/main/resources/assets/image2map/lang/zh_cn.json create mode 100644 src/securityTest/java/space/essem/image2map/SecurityRegressionTest.java diff --git a/build.gradle b/build.gradle index 212f450..532cc7b 100644 --- a/build.gradle +++ b/build.gradle @@ -42,9 +42,26 @@ dependencies { } loom { + splitEnvironmentSourceSets() + mods { + image2map { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } accessWidenerPath = file("src/main/resources/image2map.accesswidener") } +// Minecraft 26.3 no longer ships TinyFD. Bundle just its bindings and native libraries; +// Minecraft provides the matching LWJGL core. Server code never loads these classes. +dependencies { + clientImplementation include("org.lwjgl:lwjgl-tinyfd:3.4.3") { transitive = false } + ['natives-windows', 'natives-windows-arm64', 'natives-linux', 'natives-linux-arm64', + 'natives-macos', 'natives-macos-arm64'].each { platform -> + clientRuntimeOnly include("org.lwjgl:lwjgl-tinyfd:3.4.3:${platform}") { transitive = false } + } +} + processResources { inputs.property "version", project.version @@ -71,6 +88,24 @@ java { withSourcesJar() } +// Dependency-free regression harness for the security boundary and upload lifecycle. +sourceSets { + securityTest { + compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath + runtimeClasspath += sourceSets.main.runtimeClasspath + } +} +tasks.register('securityTest', JavaExec) { + dependsOn securityTestClasses + classpath = sourceSets.securityTest.runtimeClasspath + mainClass = 'space.essem.image2map.SecurityRegressionTest' + systemProperty 'java.awt.headless', 'true' + args layout.buildDirectory.dir('security-test').get().asFile.absolutePath + workingDir layout.buildDirectory.dir('security-test') + doFirst { workingDir.mkdirs() } +} +check.dependsOn securityTest + jar { from("LICENSE") { rename { "${it}_${project.archives_base_name}"} @@ -116,4 +151,4 @@ if (System.getenv("MODRINTH")) { jar { finalizedBy project.tasks.modrinth } -} \ No newline at end of file +} diff --git a/src/client/java/space/essem/image2map/client/Image2MapClient.java b/src/client/java/space/essem/image2map/client/Image2MapClient.java new file mode 100644 index 0000000..f05ab72 --- /dev/null +++ b/src/client/java/space/essem/image2map/client/Image2MapClient.java @@ -0,0 +1,221 @@ +package space.essem.image2map.client; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.multiplayer.ClientPacketListener; +import net.minecraft.network.chat.Component; +import org.lwjgl.util.tinyfd.TinyFileDialogs; +import space.essem.image2map.image.ImageFetcher; +import space.essem.image2map.image.ImageSafety; +import space.essem.image2map.network.UploadPayloads; +import space.essem.image2map.upload.UploadBuffer; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Loaded exclusively through the client entrypoint and client source set. */ +public final class Image2MapClient implements ClientModInitializer { + private final ExecutorService reader = new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1), + Thread.ofPlatform().daemon().name("image2map-client-reader").factory(), new ThreadPoolExecutor.AbortPolicy()); + private final AtomicBoolean pickerOpen = new AtomicBoolean(); + private Pending pending; + + private static final class Pending { + final UploadPayloads.Request request; + final ClientPacketListener connection; + final Screen previous; + final ImageFetcher.Resources resources = new ImageFetcher.Resources(); + final ImageSafety.Limits limits; + long deadline = Long.MAX_VALUE; + byte[] bytes; + int offset; + int sequence; + boolean accepted; + UploadProgressScreen progress; + UploadConfirmScreen confirmation; + + Pending(UploadPayloads.Request request, Minecraft client) { + this.request = request; + this.connection = client.getConnection(); + this.previous = client.gui.screen(); + // A malicious server cannot lift local safety limits. + this.limits = new ImageSafety.Limits(Math.min(request.limits().maxBytes(), 64 * 1024 * 1024L), + request.limits().formats()); + } + void startNetworkTimeout() { deadline = System.nanoTime() + request.timeoutSeconds() * 1_000_000_000L; } + } + + @Override public void onInitializeClient() { + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { + if (pending != null) end(pending, null, false); + reader.shutdownNow(); + }); + ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> { + if (ClientPlayNetworking.canSend(UploadPayloads.Capabilities.TYPE)) { + ClientPlayNetworking.send(new UploadPayloads.Capabilities(true)); + } + }); + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { + if (pending != null) end(pending, "Disconnected during image upload", false); + }); + ClientPlayNetworking.registerGlobalReceiver(UploadPayloads.Request.TYPE, (request, context) -> request(request)); + ClientPlayNetworking.registerGlobalReceiver(UploadPayloads.Status.TYPE, (status, context) -> status(status)); + ClientTickEvents.END_CLIENT_TICK.register(this::tick); + } + + private void request(UploadPayloads.Request request) { + Minecraft client = Minecraft.getInstance(); + if (pending != null) { + sendCancel(request, "Another image upload is already pending"); + return; + } + if (request.timeoutSeconds() < 1 || request.timeoutSeconds() > 3600 || request.limits().maxBytes() < 1 + || request.limits().formats().isEmpty() || request.limits().formats().size() > 32 + || !ClientPlayNetworking.canSend(UploadPayloads.Metadata.TYPE) || !ClientPlayNetworking.canSend(UploadPayloads.Chunk.TYPE) + || !ClientPlayNetworking.canSend(UploadPayloads.Complete.TYPE) || !ClientPlayNetworking.canSend(UploadPayloads.Cancel.TYPE)) { + sendCancel(request, "Invalid upload request or missing protocol channels"); + return; + } + Pending task = new Pending(request, client); + pending = task; + if (request.path().isBlank()) select(task); + else confirm(task, ImageSafety.cleanPath(request.path())); + } + + private boolean active(Pending task) { + return pending == task && Minecraft.getInstance().getConnection() == task.connection; + } + + private void progress(Pending task, Component message) { + Minecraft client = Minecraft.getInstance(); + task.progress = new UploadProgressScreen(() -> end(task, "Image upload cancelled", true)); + task.progress.message(message); + client.gui.setScreen(task.progress); + } + + private void select(Pending task) { + if (!pickerOpen.compareAndSet(false, true)) { + end(task, "Close the previous image selection dialog first", true); + return; + } + progress(task, Component.translatable("image2map.upload.selecting")); + // Native dialogs must not block Minecraft's render/network thread. One may be open at a time. + Thread.ofPlatform().daemon().name("image2map-file-picker").start(() -> { + try { + String selected = TinyFileDialogs.tinyfd_openFileDialog("Image2Map: Select an image", "", null, "Image file", false); + Minecraft.getInstance().execute(() -> { + if (!active(task)) return; + if (selected == null || selected.isBlank()) end(task, "Image selection cancelled", true); + else confirm(task, selected); + }); + } catch (Throwable exception) { + Minecraft.getInstance().execute(() -> { if (active(task)) end(task, "Could not open image selection dialog", true); }); + } finally { pickerOpen.set(false); } + }); + } + + private void confirm(Pending task, String path) { + if (!active(task)) return; + if (task.progress != null) task.progress.resolve(); + task.confirmation = new UploadConfirmScreen(path, confirmed -> { + if (!active(task)) return; + if (!confirmed) { end(task, "Image upload cancelled", true); return; } + // File contents are read only after explicit user confirmation. + progress(task, Component.translatable("image2map.upload.reading")); + try { + task.resources.track(reader.submit(() -> { + try { + Path file = Path.of(path).toAbsolutePath().normalize(); // Relative to the process working directory. + byte[] bytes = ImageFetcher.file(file, task.limits, task.resources); + // Decode once locally to reject corrupt/non-image files before sending any data. + var decoded = ImageSafety.decode(bytes, task.limits); + var info = decoded.info(); + decoded.image().flush(); + Minecraft.getInstance().execute(() -> { + if (!active(task)) return; + task.bytes = bytes; + task.startNetworkTimeout(); + ClientPlayNetworking.send(new UploadPayloads.Metadata(task.request.requestId(), info.format(), bytes.length, + info.width(), info.height(), task.request.mode())); + }); + } catch (Exception exception) { + Minecraft.getInstance().execute(() -> { + if (active(task)) end(task, "Could not read image: " + error(exception), true); + }); + } + })); + } catch (RuntimeException exception) { + end(task, "Image reader is busy; please try again later", true); + } + }); + Minecraft.getInstance().gui.setScreen(task.confirmation); + } + + private void status(UploadPayloads.Status status) { + Pending task = pending; + if (task == null || !active(task) || !task.request.requestId().equals(status.requestId())) return; + if (status.terminal()) { + end(task, status.accepted() ? null : status.message(), false); + } else if (status.accepted() && task.bytes != null && !task.accepted) { + task.accepted = true; + task.startNetworkTimeout(); + } else end(task, "Unexpected image upload response", true); + } + + private void tick(Minecraft client) { + Pending task = pending; + if (task == null) return; + if (!active(task)) { end(task, "Disconnected during image upload", false); return; } + if (System.nanoTime() >= task.deadline) { end(task, "Image upload timed out", true); return; } + if (!task.accepted || task.bytes == null) return; + // Pace uploads to four 16 KiB chunks per tick instead of flooding the connection. + for (int count = 0; count < 4 && task.offset < task.bytes.length; count++) { + int end = Math.min(task.offset + UploadBuffer.CHUNK_SIZE, task.bytes.length); + ClientPlayNetworking.send(new UploadPayloads.Chunk(task.request.requestId(), task.sequence++, Arrays.copyOfRange(task.bytes, task.offset, end))); + task.offset = end; + } + task.progress.message(Component.translatable("image2map.upload.progress", task.offset * 100L / task.bytes.length)); + if (task.offset == task.bytes.length) { + ClientPlayNetworking.send(new UploadPayloads.Complete(task.request.requestId())); + task.bytes = null; + // Receiving and rendering the image may outlive the network transfer. + task.deadline = Long.MAX_VALUE; + task.progress.message(Component.translatable("image2map.upload.processing")); + } + } + + private void end(Pending task, String message, boolean notifyServer) { + if (pending != task) return; + boolean connected = active(task); + pending = null; + task.resources.close(); + task.bytes = null; + if (notifyServer && connected) sendCancel(task.request, message == null ? "Cancelled" : message); + if (task.progress != null) task.progress.resolve(); + Minecraft client = Minecraft.getInstance(); + if (client.gui.screen() == task.progress || client.gui.screen() == task.confirmation) client.gui.setScreen(connected ? task.previous : null); + if (message != null && client.player != null) client.player.sendSystemMessage(Component.literal("Image2Map: " + message)); + } + + private static void sendCancel(UploadPayloads.Request request, String message) { + if (ClientPlayNetworking.canSend(UploadPayloads.Cancel.TYPE)) { + ClientPlayNetworking.send(new UploadPayloads.Cancel(request.requestId(), message.length() > 512 ? message.substring(0, 512) : message)); + } + } + + private static String error(Exception exception) { + String message = exception.getMessage(); + if (message == null) return exception.getClass().getSimpleName(); + return message.length() > 400 ? message.substring(0, 400) : message; + } +} diff --git a/src/client/java/space/essem/image2map/client/UploadConfirmScreen.java b/src/client/java/space/essem/image2map/client/UploadConfirmScreen.java new file mode 100644 index 0000000..5cc8168 --- /dev/null +++ b/src/client/java/space/essem/image2map/client/UploadConfirmScreen.java @@ -0,0 +1,38 @@ +package space.essem.image2map.client; + +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.network.chat.Component; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** ESC and replacement by another screen both cancel an unresolved confirmation. */ +final class UploadConfirmScreen extends ConfirmScreen { + private final AtomicBoolean answered; + private final BooleanConsumer callback; + + UploadConfirmScreen(String path, BooleanConsumer callback) { + this(path, callback, new AtomicBoolean()); + } + + private UploadConfirmScreen(String path, BooleanConsumer callback, AtomicBoolean answered) { + super(value -> { + if (!answered.getAndSet(true)) callback.accept(value); + }, Component.translatable("image2map.upload.confirm.title"), + Component.translatable("image2map.upload.confirm.message", path), + Component.translatable("image2map.upload.confirm.yes"), Component.translatable("gui.cancel")); + this.answered = answered; + this.callback = callback; + } + + @Override public void onClose() { + if (!answered.getAndSet(true)) callback.accept(false); + } + + @Override public void removed() { + if (!answered.getAndSet(true)) callback.accept(false); + super.removed(); + } + + @Override public boolean isPauseScreen() { return false; } +} diff --git a/src/client/java/space/essem/image2map/client/UploadProgressScreen.java b/src/client/java/space/essem/image2map/client/UploadProgressScreen.java new file mode 100644 index 0000000..59a417d --- /dev/null +++ b/src/client/java/space/essem/image2map/client/UploadProgressScreen.java @@ -0,0 +1,39 @@ +package space.essem.image2map.client; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +final class UploadProgressScreen extends Screen { + private final Runnable cancel; + private boolean resolved; + private Component message = Component.translatable("image2map.upload.selecting"); + + UploadProgressScreen(Runnable cancel) { + super(Component.translatable("image2map.upload.title")); + this.cancel = cancel; + } + + void message(Component value) { message = value; } + void resolve() { resolved = true; } + @Override protected void init() { + addRenderableWidget(Button.builder(Component.translatable("gui.cancel"), button -> onClose()) + .bounds(width / 2 - 75, height / 2 + 25, 150, 20).build()); + } + + @Override public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { + super.extractRenderState(graphics, mouseX, mouseY, delta); + graphics.centeredText(font, title, width / 2, height / 2 - 35, 0xffffffff); + graphics.centeredText(font, message, width / 2, height / 2 - 10, 0xffffffff); + } + + @Override public void onClose() { + if (!resolved) { + resolved = true; + cancel.run(); + } + } + @Override public void removed() { onClose(); super.removed(); } + @Override public boolean isPauseScreen() { return false; } +} diff --git a/src/main/java/space/essem/image2map/Image2Map.java b/src/main/java/space/essem/image2map/Image2Map.java index fadb14b..ceb23fe 100644 --- a/src/main/java/space/essem/image2map/Image2Map.java +++ b/src/main/java/space/essem/image2map/Image2Map.java @@ -1,30 +1,14 @@ package space.essem.image2map; -import com.mojang.brigadier.arguments.IntegerArgumentType; -import com.mojang.brigadier.arguments.StringArgumentType; -import com.mojang.brigadier.context.CommandContext; -import com.mojang.brigadier.exceptions.CommandSyntaxException; -import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; -import com.mojang.brigadier.suggestion.SuggestionProvider; -import com.mojang.brigadier.suggestion.Suggestions; -import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.mojang.logging.LogUtils; -import eu.pb4.sgui.api.SguiUtils; import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; -import net.fabricmc.loader.api.FabricLoader; import net.minecraft.ChatFormatting; -import net.minecraft.commands.CommandSourceStack; import net.minecraft.core.Direction; import net.minecraft.core.component.DataComponentPatch; import net.minecraft.core.component.DataComponents; -import net.minecraft.nbt.*; +import net.minecraft.nbt.NbtOps; import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.HoverEvent; -import net.minecraft.network.chat.Style; -import net.minecraft.resources.Identifier; -import net.minecraft.server.permissions.PermissionLevel; import net.minecraft.util.Mth; import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.Entity; @@ -38,365 +22,33 @@ import net.minecraft.world.item.component.ItemLore; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import space.essem.image2map.config.Image2MapConfig; -import space.essem.image2map.gui.PreviewGui; -import space.essem.image2map.renderer.MapRenderer; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.net.URL; -import java.net.URLConnection; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.FileVisitResult; -import java.nio.file.FileVisitor; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.time.Duration; -import java.time.temporal.TemporalUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static net.minecraft.commands.Commands.argument; -import static net.minecraft.commands.Commands.literal; +import space.essem.image2map.network.UploadPayloads; +import space.essem.image2map.upload.ServerImageTasks; +import java.util.List; public class Image2Map implements ModInitializer { public static final Logger LOGGER = LogUtils.getLogger(); - - public static Image2MapConfig CONFIG = Image2MapConfig.loadOrCreateConfig(); + public static final Image2MapConfig CONFIG = Image2MapConfig.loadOrCreateConfig(); + public static final ServerImageTasks TASKS = new ServerImageTasks(); @Override public void onInitialize() { - CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { - dispatcher.register(literal("image2map") - .requires(FabricPermissionBridge.require(id("use"), PermissionLevel.byId(CONFIG.minPermLevel))) - .then(literal("create") - .requires(FabricPermissionBridge.require(id("create"), true)) - .then(argument("width", IntegerArgumentType.integer(1, CONFIG.maxSize)) - .then(argument("height", IntegerArgumentType.integer(1, CONFIG.maxSize)) - .then(argument("mode", StringArgumentType.word()).suggests(new DitherModeSuggestionProvider()) - .then(argument("path", StringArgumentType.greedyString()) - .executes(this::createMap)) - ) - ) - ) - .then(argument("mode", StringArgumentType.word()).suggests(new DitherModeSuggestionProvider()) - .then(argument("path", StringArgumentType.greedyString()) - .executes(this::createMap) - ) - ) - ) - .then(literal("create-folder") - .requires(FabricPermissionBridge.require(id("createfolder"), PermissionLevel.ADMINS).and(x -> CONFIG.allowLocalFiles)) - .then(argument("width", IntegerArgumentType.integer(1, CONFIG.maxSize)) - .then(argument("height", IntegerArgumentType.integer(1, CONFIG.maxSize)) - .then(argument("mode", StringArgumentType.word()).suggests(new DitherModeSuggestionProvider()) - .then(argument("path", StringArgumentType.greedyString()) - .executes(this::createMapFromFolder)) - ) - ) - ) - .then(argument("mode", StringArgumentType.word()).suggests(new DitherModeSuggestionProvider()) - .then(argument("path", StringArgumentType.greedyString()) - .executes(this::createMapFromFolder) - ) - ) - ) - .then(literal("preview") - .requires(FabricPermissionBridge.require(id("preview"), true)) - .then(argument("path", StringArgumentType.greedyString()) - .executes(this::openPreview) - ) - ) - ); - }); - - ServerLifecycleEvents.SERVER_STARTED.register((s) -> CardboardWarning.checkAndAnnounce()); - } - - private static Identifier id(String use) { - return Identifier.fromNamespaceAndPath("image2map", use); - } - - private int openPreview(CommandContext context) throws CommandSyntaxException { - CommandSourceStack source = context.getSource(); - String input = StringArgumentType.getString(context, "path"); - - source.sendSuccess(() -> Component.literal("Getting image..."), false); - - getImage(input).orTimeout(30, TimeUnit.SECONDS).handleAsync((image, ex) -> { - if (ex instanceof TimeoutException) { - source.sendSuccess(() -> Component.literal("Downloading or reading of the image took too long!"), false); - return null; - } else if (ex != null) { - if (ex instanceof RuntimeException ru && ru.getCause() != null) { - ex = ru.getCause(); - } - - Throwable finalEx = ex; - source.sendSuccess(() -> Component.literal("The image isn't valid (hover for more info)!") - .setStyle(Style.EMPTY.withColor(ChatFormatting.RED).withHoverEvent(new HoverEvent.ShowText(Component.literal(finalEx.getMessage())))), false); - return null; - } - - if (image == null) { - source.sendSuccess(() -> Component.literal("That doesn't seem to be a valid image (unknown reason)!"), false); - return null; - } - - if (SguiUtils.getCurrentGui(source.getPlayer()) instanceof PreviewGui previewGui) { - previewGui.close(); - } - - var width = image.getWidth(); - var height = image.getHeight(); - - if (height > CONFIG.maxSize || width > CONFIG.maxSize) { - var scaleDown = Math.min(CONFIG.maxSize / (double) height, CONFIG.maxSize / (double) width); - width = (int) (width * scaleDown); - height = (int) (height * scaleDown); - } - - new PreviewGui(context.getSource().getPlayer(), image, input, DitherMode.NONE, width, height); - - return null; - }, source.getServer()); - - return 1; - } - - class DitherModeSuggestionProvider implements SuggestionProvider { - - @Override - public CompletableFuture getSuggestions(CommandContext context, - SuggestionsBuilder builder) throws CommandSyntaxException { - builder.suggest("none"); - builder.suggest("dither"); - return builder.buildFuture(); - } - + UploadPayloads.register(); + TASKS.register(); + ImageCommands.register(); + ServerLifecycleEvents.SERVER_STARTED.register(server -> CardboardWarning.checkAndAnnounce()); } public enum DitherMode { - NONE, - FLOYD; - - public static DitherMode fromString(String string) { - if (string.equalsIgnoreCase("NONE")) - return DitherMode.NONE; - else if (string.equalsIgnoreCase("DITHER") || string.equalsIgnoreCase("FLOYD")) - return DitherMode.FLOYD; - throw new IllegalArgumentException("invalid dither mode"); - } - } - - private CompletableFuture getImage(String input) { - return CompletableFuture.supplyAsync(() -> { - try { - if (isValid(input)) { - try(var client = HttpClient.newHttpClient()) { - var req = HttpRequest.newBuilder().GET().uri(URI.create(input)).timeout(Duration.ofSeconds(30)) - .setHeader("User-Agent", "Image2Map mod").build(); - - var stream = client.send(req, HttpResponse.BodyHandlers.ofInputStream()); - return ImageIO.read(stream.body()); - } - } else if (CONFIG.allowLocalFiles) { - var path = FabricLoader.getInstance().getGameDir().resolve(input); - if (Files.exists(path)) { - return ImageIO.read(Files.newInputStream(path)); - } - return null; - } else { - return null; - } - } catch (Throwable e) { - LOGGER.warn("Failed to load the image!", e); - throw new RuntimeException(e); - } - }); - } - - private List getImageFromFolder(String input) { - if (CONFIG.allowLocalFiles) { - try { - var arr = new ArrayList(); - var path = FabricLoader.getInstance().getGameDir().resolve(input); - if (Files.exists(path) && Files.isDirectory(path)) { - Files.walkFileTree(path, new FileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - try { - var x = ImageIO.read(Files.newInputStream(file)); - if (x != null) { - arr.add(x); - } - }catch (Throwable e) { - e.printStackTrace(); - } - - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - return FileVisitResult.CONTINUE; - } - }); - } - return arr; - } catch (Throwable e) { - throw new RuntimeException(e); - } + NONE, FLOYD; + public static DitherMode fromString(String value) { + if (value.equalsIgnoreCase("none")) return NONE; + if (value.equalsIgnoreCase("dither") || value.equalsIgnoreCase("floyd")) return FLOYD; + throw new IllegalArgumentException("Invalid dither mode: " + value); } - - return List.of(); - } - - private int createMap(CommandContext context) throws CommandSyntaxException { - CommandSourceStack source = context.getSource(); - - Player player = source.getPlayer(); - DitherMode mode; - String modeStr = StringArgumentType.getString(context, "mode"); - try { - mode = DitherMode.fromString(modeStr); - } catch (IllegalArgumentException e) { - throw new SimpleCommandExceptionType(() -> "Invalid dither mode '" + modeStr + "'").create(); - } - - String input = StringArgumentType.getString(context, "path"); - - source.sendSuccess(() -> Component.literal("Getting image..."), false); - - getImage(input).orTimeout(20, TimeUnit.SECONDS).handleAsync((image, ex) -> { - if (ex instanceof TimeoutException) { - source.sendSuccess(() -> Component.literal("Downloading or reading of the image took too long!"), false); - return null; - } else if (ex != null) { - if (ex instanceof RuntimeException ru && ru.getCause() != null) { - ex = ru.getCause(); - } - - Throwable finalEx = ex; - source.sendSuccess(() -> Component.literal("The image isn't valid (hover for more info)!") - .setStyle(Style.EMPTY.withColor(ChatFormatting.RED).withHoverEvent(new HoverEvent.ShowText(Component.literal(finalEx.getMessage())))), false); - return null; - } - - if (image == null) { - source.sendSuccess(() -> Component.literal("That doesn't seem to be a valid image (unknown reason)!"), false); - return null; - } - - int width; - int height; - - try { - width = IntegerArgumentType.getInteger(context, "width"); - height = IntegerArgumentType.getInteger(context, "height"); - - if (height > CONFIG.maxSize || width > CONFIG.maxSize) { - int finalHeight = height; - int finalWidth = width; - source.sendSuccess(() -> Component.literal("Map size exceeds maximum allowed (" + CONFIG.maxSize + "x" + CONFIG.maxSize + "), was " + finalWidth + "x" + finalHeight), false); - return null; - } - } catch (Throwable e) { - width = image.getWidth(); - height = image.getHeight(); - - if (height > CONFIG.maxSize || width > CONFIG.maxSize) { - var scaleDown = Math.min(CONFIG.maxSize / (double) height, CONFIG.maxSize / (double) width); - width = (int) (width * scaleDown); - height = (int) (height * scaleDown); - } - } - - int finalHeight = height; - int finalWidth = width; - - source.sendSuccess(() -> Component.literal("Converting into maps..."), false); - - CompletableFuture.supplyAsync(() -> MapRenderer.render(image, mode, finalWidth, finalHeight)).thenAcceptAsync(mapImage -> { - var items = MapRenderer.toVanillaItems(mapImage, source.getLevel(), input); - giveToPlayer(player, items, input, finalWidth, finalHeight); - source.sendSuccess(() -> Component.literal("Done!"), false); - }, source.getServer()); - return null; - }, source.getServer()); - - return 1; - } - - private int createMapFromFolder(CommandContext context) throws CommandSyntaxException { - CommandSourceStack source = context.getSource(); - - Player player = source.getPlayer(); - DitherMode mode; - String modeStr = StringArgumentType.getString(context, "mode"); - try { - mode = DitherMode.fromString(modeStr); - } catch (IllegalArgumentException e) { - throw new SimpleCommandExceptionType(() -> "Invalid dither mode '" + modeStr + "'").create(); - } - - String input = StringArgumentType.getString(context, "path"); - - source.sendSuccess(() -> Component.literal("Getting image..."), false); - - var list = new ArrayList(); - - for (var image : getImageFromFolder(input)) { - int width; - int height; - - try { - width = IntegerArgumentType.getInteger(context, "width"); - height = IntegerArgumentType.getInteger(context, "height"); - } catch (Throwable e) { - width = image.getWidth(); - height = image.getHeight(); - } - - int finalHeight = height; - int finalWidth = width; - - if (finalHeight > CONFIG.maxSize || finalWidth > CONFIG.maxSize) { - throw new SimpleCommandExceptionType(() -> "Map size exceeds maximum allowed (1024x1024), was " + finalWidth + "x" + finalHeight).create(); - } - source.sendSuccess(() -> Component.literal("Converting into maps..."), false); - - var mapImage = MapRenderer.render(image, mode, finalWidth, finalHeight); - var items = MapRenderer.toVanillaItems(mapImage, source.getLevel(), input); - list.add(toSingleStack(items, input, width, height)); - } - var bundle = new ItemStack(Items.BUNDLE); - bundle.set(DataComponents.BUNDLE_CONTENTS, new BundleContents(list)); - player.addItem(bundle); - - return 1; } public static void giveToPlayer(Player player, List items, String input, int width, int height) { @@ -547,12 +199,4 @@ public static boolean destroyItemFrame(Entity player, ItemFrame itemFrameEntity) return false; } - private static boolean isValid(String url) { - try { - new URL(url).toURI(); - return true; - } catch (Exception e) { - return false; - } - } } diff --git a/src/main/java/space/essem/image2map/ImageCommands.java b/src/main/java/space/essem/image2map/ImageCommands.java new file mode 100644 index 0000000..6f03762 --- /dev/null +++ b/src/main/java/space/essem/image2map/ImageCommands.java @@ -0,0 +1,83 @@ +package space.essem.image2map; + +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.resources.Identifier; +import net.minecraft.server.permissions.PermissionLevel; +import space.essem.image2map.upload.ServerImageTasks; +import space.essem.image2map.config.Image2MapConfig; + +import static net.minecraft.commands.Commands.argument; +import static net.minecraft.commands.Commands.literal; + +/** Commands remain server-side so vanilla and older clients retain URL support. */ +public final class ImageCommands { + private ImageCommands() { } + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(root(Image2Map.CONFIG))); + } + + static LiteralArgumentBuilder root(Image2MapConfig config) { + return literal("image2map") + .requires(FabricPermissionBridge.require(id("use"), PermissionLevel.byId(config.minPermLevel))) + .then(create("create", false, config)) + .then(create("create-folder", true, config)) + .then(literal("preview").requires(FabricPermissionBridge.require(id("preview"), true)) + .executes(context -> run(context, true, false)) + .then(argument("path", StringArgumentType.greedyString()).executes(context -> run(context, true, false)))); + } + + private static LiteralArgumentBuilder create(String name, boolean folder, Image2MapConfig config) { + var command = literal(name); + if (folder) command.requires(FabricPermissionBridge.require(id("createfolder"), PermissionLevel.ADMINS) + .and(source -> config.allowServerLocalFiles)); + else command.requires(FabricPermissionBridge.require(id("create"), true)) + .executes(context -> run(context, false, false)); + command.then(argument("width", IntegerArgumentType.integer(1, config.imageMaxWidthHeight)) + .then(argument("height", IntegerArgumentType.integer(1, config.imageMaxWidthHeight)).then(mode(folder)))); + return command.then(mode(folder)); + } + + private static RequiredArgumentBuilder mode(boolean folder) { + var mode = argument("mode", StringArgumentType.word()).suggests((context, builder) -> { + builder.suggest("none"); + builder.suggest("dither"); + return builder.buildFuture(); + }); + if (!folder) mode.executes(context -> run(context, false, false)); + return mode.then(argument("path", StringArgumentType.greedyString()).executes(context -> run(context, false, folder))); + } + + private static int run(CommandContext context, boolean preview, boolean folder) throws CommandSyntaxException { + var source = context.getSource(); + var player = source.getPlayerOrException(); // Reject console before creating a job. + Image2Map.DitherMode mode = Image2Map.DitherMode.NONE; + if (!preview) { + try { mode = Image2Map.DitherMode.fromString(optional(context, "mode", "none")); } + catch (IllegalArgumentException exception) { + throw new SimpleCommandExceptionType(() -> exception.getMessage()).create(); + } + } + int width = 0, height = 0; + try { + width = IntegerArgumentType.getInteger(context, "width"); + height = IntegerArgumentType.getInteger(context, "height"); + } catch (IllegalArgumentException ignored) { } + return Image2Map.TASKS.start(source, player, optional(context, "path", ""), + new ServerImageTasks.Options(mode, width, height, preview, folder)); + } + + private static String optional(CommandContext context, String name, String fallback) { + try { return StringArgumentType.getString(context, name); } + catch (IllegalArgumentException ignored) { return fallback; } + } + private static Identifier id(String name) { return Identifier.fromNamespaceAndPath("image2map", name); } +} diff --git a/src/main/java/space/essem/image2map/config/ConfigMigration.java b/src/main/java/space/essem/image2map/config/ConfigMigration.java new file mode 100644 index 0000000..63bc3d2 --- /dev/null +++ b/src/main/java/space/essem/image2map/config/ConfigMigration.java @@ -0,0 +1,21 @@ +package space.essem.image2map.config; + +import com.google.gson.JsonObject; + +/** Migrates the old schema before deserialization and before any save. */ +public final class ConfigMigration { + private ConfigMigration() { } + public static void migrate(JsonObject object) { + if (object.has("version")) return; + rename(object, "maxSize", "imageMaxWidthHeight"); + rename(object, "allowLocalFiles", "allowServerLocalFiles"); + object.addProperty("version", 2); + } + + private static void rename(JsonObject object, String oldName, String newName) { + if (object.has(oldName)) { + if (!object.has(newName)) object.add(newName, object.get(oldName)); + object.remove(oldName); + } + } +} diff --git a/src/main/java/space/essem/image2map/config/Image2MapConfig.java b/src/main/java/space/essem/image2map/config/Image2MapConfig.java index 9f074ba..f299e6b 100644 --- a/src/main/java/space/essem/image2map/config/Image2MapConfig.java +++ b/src/main/java/space/essem/image2map/config/Image2MapConfig.java @@ -2,56 +2,81 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonParser; import net.fabricmc.loader.api.FabricLoader; -import org.apache.commons.io.IOUtils; import space.essem.image2map.Image2Map; +import space.essem.image2map.image.ImageSafety; -import java.io.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; public class Image2MapConfig { - private static final Gson GSON = new GsonBuilder() - .disableHtmlEscaping().setLenient().setPrettyPrinting() - .create(); + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + public int version = 2; + public boolean allowServerLocalFiles = false; + public boolean allowClientUploadFiles = true; + public int minPermLevel = 2; + public int imageMaxWidthHeight = 2048; + public int imageFileMaxSize = 20480; // KiB, encoded file size (20 MiB) + public List allowedImageFormats = List.of("png", "jpeg", "gif", "bmp", "webp"); + public int imageOperationCooldownSeconds = 5; + public int networkTimeout = 30; - public boolean allowLocalFiles = false; - - public int minPermLevel = 2; - public int maxSize = 2048; - - public static Image2MapConfig loadOrCreateConfig() { - try { - Image2MapConfig config; - File configFile = new File(FabricLoader.getInstance().getConfigDir().toFile(), "image2map.json"); - - if (configFile.exists()) { - String json = IOUtils.toString(new InputStreamReader(new FileInputStream(configFile), StandardCharsets.UTF_8)); - - config = GSON.fromJson(json, Image2MapConfig.class); - } else { - config = new Image2MapConfig(); - } + public ImageSafety.Limits imageLimits() { + return new ImageSafety.Limits(imageFileMaxSize * 1024L, allowedImageFormats); + } + public static Image2MapConfig fromJson(String json) { + var object = JsonParser.parseString(json).getAsJsonObject(); + ConfigMigration.migrate(object); + Image2MapConfig config = GSON.fromJson(object, Image2MapConfig.class); + config.validate(); + return config; + } - saveConfig(config); - return config; + private void validate() { + if (version != 2 || minPermLevel < 0 || minPermLevel > 4 || imageMaxWidthHeight < 1 + || imageMaxWidthHeight > 16384 || imageFileMaxSize < 1 || imageFileMaxSize > 262144 + || imageOperationCooldownSeconds < 0 || networkTimeout < 1 || networkTimeout > 3600 + || allowedImageFormats == null || allowedImageFormats.isEmpty() || allowedImageFormats.size() > 32 + || allowedImageFormats.stream().anyMatch(x -> x == null || !x.matches("[A-Za-z0-9]{1,32}"))) { + throw new IllegalArgumentException("Invalid image2map configuration limits or version"); + } } - catch(IOException exception) { - Image2Map.LOGGER.error("Something went wrong while reading config!"); - exception.printStackTrace(); - return new Image2MapConfig(); + + public static Image2MapConfig loadOrCreateConfig() { + Path path = FabricLoader.getInstance().getConfigDir().resolve("image2map.json"); + try { + if (!Files.exists(path)) { + Image2MapConfig config = new Image2MapConfig(); + saveConfig(config); + return config; + } + String json = Files.readString(path, StandardCharsets.UTF_8); + boolean needsMigration = !JsonParser.parseString(json).getAsJsonObject().has("version"); + Image2MapConfig config = fromJson(json); + if (needsMigration) { + Path backup = path.resolveSibling("image2map.json.v1.bak"); + if (!Files.exists(backup)) Files.copy(path, backup); + saveConfig(config); + } + return config; + } catch (IOException | RuntimeException exception) { + Image2Map.LOGGER.error("Failed to read image2map config; using defaults and preserving the file", exception); + return new Image2MapConfig(); + } } - } - - public static void saveConfig(Image2MapConfig config) { - File configFile = new File(FabricLoader.getInstance().getConfigDir().toFile(), "image2map.json"); - try { - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(configFile), StandardCharsets.UTF_8)); - writer.write(GSON.toJson(config)); - writer.close(); - } catch (Exception e) { - Image2Map.LOGGER.error("Something went wrong while saving config!"); - e.printStackTrace(); + + public static void saveConfig(Image2MapConfig config) { + Path path = FabricLoader.getInstance().getConfigDir().resolve("image2map.json"); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, GSON.toJson(config), StandardCharsets.UTF_8); + } catch (IOException exception) { + Image2Map.LOGGER.error("Failed to save image2map config", exception); + } } - } -} \ No newline at end of file +} diff --git a/src/main/java/space/essem/image2map/gui/PreviewGui.java b/src/main/java/space/essem/image2map/gui/PreviewGui.java index f38ad69..1c55b0f 100644 --- a/src/main/java/space/essem/image2map/gui/PreviewGui.java +++ b/src/main/java/space/essem/image2map/gui/PreviewGui.java @@ -6,10 +6,8 @@ import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.RequiredArgumentBuilder; -import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.tree.ArgumentCommandNode; import com.mojang.brigadier.tree.CommandNode; -import com.mojang.brigadier.tree.RootCommandNode; import eu.pb4.mapcanvas.api.core.CanvasColor; import eu.pb4.mapcanvas.api.core.CanvasImage; import eu.pb4.mapcanvas.api.font.DefaultFonts; @@ -19,7 +17,7 @@ import space.essem.image2map.renderer.MapRenderer; import java.awt.image.BufferedImage; -import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import net.minecraft.commands.synchronization.SuggestionProviders; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.ClientboundCommandsPacket; @@ -40,15 +38,18 @@ public class PreviewGui extends MapGui { private int width; private int height; private boolean grid = true; - private CompletableFuture imageProcessing; + private Future imageProcessing; + private final Runnable onClosed; + private boolean closed; - public PreviewGui(ServerPlayer player, BufferedImage image, String source, Image2Map.DitherMode ditherMode, int width, int height) { + public PreviewGui(ServerPlayer player, BufferedImage image, String source, Image2Map.DitherMode ditherMode, int width, int height, Runnable onClosed) { super(player, Mth.ceil(width / 128d) + 2, Mth.ceil(height / 128d) + 2); this.width = width; this.height = height; this.ditherMode = ditherMode; this.source = source; this.sourceImage = image; + this.onClosed = onClosed; player.connection.send(new ClientboundCommandsPacket(COMMANDS.getRoot(), new ClientboundCommandsPacket.NodeInspector<>() { @Nullable @@ -81,32 +82,34 @@ protected void updateImage() { @Override public void onTick() { + if (closed) return; if (this.dirty) { if (this.imageProcessing != null) { this.imageProcessing.cancel(true); } - this.imageProcessing = CompletableFuture.supplyAsync(() -> MapRenderer.render(this.sourceImage, this.ditherMode, this.width, this.height)); + try { + this.imageProcessing = Image2Map.TASKS.renderPreview(this.sourceImage, this.ditherMode, this.width, this.height); + } catch (RuntimeException exception) { + this.player.sendSystemMessage(Component.literal("Image processing queue is full")); + this.close(); + return; + } this.dirty = false; } if (this.imageProcessing != null) { if (this.imageProcessing.isDone()) { - if (this.imageProcessing.isCompletedExceptionally()) { + try { + this.image = this.imageProcessing.get(); this.imageProcessing = null; - } else { - try { - this.image = this.imageProcessing.get(); - this.imageProcessing = null; - - this.xPos = (this.canvas.getWidth() - this.image.getWidth()) / 2; - this.yPos = (this.canvas.getHeight() - this.image.getHeight()) / 2; - - this.draw(); - } catch (Throwable e) { - e.printStackTrace(); - this.close(); - } + this.xPos = (this.canvas.getWidth() - this.image.getWidth()) / 2; + this.yPos = (this.canvas.getHeight() - this.image.getHeight()) / 2; + this.draw(); + } catch (Exception exception) { + Image2Map.LOGGER.warn("Preview rendering failed", exception); + this.player.sendSystemMessage(Component.literal("Preview image processing failed")); + this.close(); } } } @@ -114,15 +117,18 @@ public void onTick() { @Override public void onManualClose() { + if (this.closed) return; + this.closed = true; if (this.imageProcessing != null) { this.imageProcessing.cancel(true); } - super.onManualClose(); + try { super.onManualClose(); } + finally { if (this.onClosed != null) this.onClosed.run(); } } private void drawLoading() { var text = "Loading..."; - var size = (int) Math.min(this.height / 128d, this.width / 128d) * 16; + var size = Math.max(8, (int) Math.min(this.height / 128d, this.width / 128d) * 16); var width = DefaultFonts.VANILLA.getTextWidth(text, size); CanvasUtils.fill(this.canvas, @@ -137,6 +143,7 @@ private void drawLoading() { } private void draw() { + if (this.image == null) return; var image = new CanvasImage(this.canvas.getWidth(), this.canvas.getHeight()); if (this.grid) { @@ -160,6 +167,10 @@ private void draw() { } public void setSize(int width, int height) { + if (width < 1 || height < 1 || width > Image2Map.CONFIG.imageMaxWidthHeight || height > Image2Map.CONFIG.imageMaxWidthHeight) { + this.player.sendSystemMessage(Component.literal("Map size exceeds the configured output limit")); + return; + } if ( this.canvas.getWidth() < width + 256 || this.canvas.getHeight() < height + 256 || this.canvas.getWidth() > width * 2 || this.canvas.getHeight() > height * 2 @@ -186,8 +197,8 @@ public void setDrawGrid(boolean grid) { public void executeCommand(String command) { try { COMMANDS.execute(command, this); - } catch (Throwable e) { - e.printStackTrace(); + } catch (Exception e) { + this.player.sendSystemMessage(Component.literal("Preview command failed: " + e.getMessage())); } } @@ -206,13 +217,13 @@ private static RequiredArgumentBuilder argument(String name, })); COMMANDS.register(literal("save").executes(x -> { - if (x.getSource().imageProcessing == null) { + if (!x.getSource().dirty && x.getSource().imageProcessing == null && x.getSource().image != null) { x.getSource().drawLoading(); - Image2Map.giveToPlayer(x.getSource().player, - MapRenderer.toVanillaItems(x.getSource().image, x.getSource().player.level(), x.getSource().source), - x.getSource().source, x.getSource().width, x.getSource().height); - - x.getSource().close(); + try { + Image2Map.giveToPlayer(x.getSource().player, + MapRenderer.toVanillaItems(x.getSource().image, x.getSource().player.level(), x.getSource().source), + x.getSource().source, x.getSource().width, x.getSource().height); + } finally { x.getSource().close(); } } else { x.getSource().player.sendSystemMessage(Component.literal("Image is still processed!")); } @@ -220,13 +231,13 @@ private static RequiredArgumentBuilder argument(String name, })); COMMANDS.register(literal("size") - .then(argument("width", IntegerArgumentType.integer(1, Image2Map.CONFIG.maxSize)) + .then(argument("width", IntegerArgumentType.integer(1, Image2Map.CONFIG.imageMaxWidthHeight)) .executes(x -> { var w = IntegerArgumentType.getInteger(x, "width"); - x.getSource().setSize(w, x.getSource().sourceImage.getHeight() * w / x.getSource().sourceImage.getWidth()); + x.getSource().setSize(w, Math.max(1, (int) ((long) x.getSource().sourceImage.getHeight() * w / x.getSource().sourceImage.getWidth()))); return 0; }) - .then(argument("height", IntegerArgumentType.integer(1, Image2Map.CONFIG.maxSize)).executes(x -> { + .then(argument("height", IntegerArgumentType.integer(1, Image2Map.CONFIG.imageMaxWidthHeight)).executes(x -> { x.getSource().setSize(IntegerArgumentType.getInteger(x, "width"), IntegerArgumentType.getInteger(x, "height")); return 0; }))) diff --git a/src/main/java/space/essem/image2map/image/ImageFetcher.java b/src/main/java/space/essem/image2map/image/ImageFetcher.java new file mode 100644 index 0000000..b6467cc --- /dev/null +++ b/src/main/java/space/essem/image2map/image/ImageFetcher.java @@ -0,0 +1,80 @@ +package space.essem.image2map.image; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.Future; + +/** Shared bounded loading for HTTP and safe server files. */ +public final class ImageFetcher { + private final HttpClient client; + private final int timeout; + + public ImageFetcher(int timeout) { + this.timeout = timeout; + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(timeout)) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + /** Closing this handle interrupts downloads even while a body read is stalled. */ + public static final class Resources implements AutoCloseable { + private InputStream stream; + private Future future; + private boolean closed; + + public synchronized void track(Future value) { + if (closed) value.cancel(true); + else future = value; + } + + public synchronized void track(InputStream value) throws IOException { + if (closed) { + value.close(); + throw new IOException("Image task cancelled"); + } + stream = value; + } + + @Override public synchronized void close() { + closed = true; + if (future != null) future.cancel(true); + if (stream != null) { + try { stream.close(); } catch (IOException ignored) { } + stream = null; + } + } + } + + public byte[] download(String url, ImageSafety.Limits limits, Resources resources) throws Exception { + var request = HttpRequest.newBuilder(URI.create(url)).GET().timeout(Duration.ofSeconds(timeout)) + .header("User-Agent", "Image2Map mod").build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream stream = response.body()) { + resources.track(stream); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("Image download failed: HTTP " + response.statusCode()); + } + long length = response.headers().firstValueAsLong("Content-Length").orElse(-1); + if (length > limits.maxBytes()) throw new IOException("Image download exceeds the allowed byte size"); + return ImageSafety.readBounded(stream, limits.maxBytes()); + } + } + + public static byte[] file(Path path, ImageSafety.Limits limits, Resources resources) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Only regular image files are allowed"); + if (Files.size(path) > limits.maxBytes()) throw new IOException("Image file exceeds the allowed byte size"); + try (InputStream stream = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) { + resources.track(stream); + return ImageSafety.readBounded(stream, limits.maxBytes()); + } + } + + public void close() { client.shutdownNow(); } +} diff --git a/src/main/java/space/essem/image2map/image/ImageSafety.java b/src/main/java/space/essem/image2map/image/ImageSafety.java new file mode 100644 index 0000000..5452aa3 --- /dev/null +++ b/src/main/java/space/essem/image2map/image/ImageSafety.java @@ -0,0 +1,130 @@ +package space.essem.image2map.image; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.MemoryCacheImageInputStream; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; + +public final class ImageSafety { + private ImageSafety() { } + + public record Limits(long maxBytes, List formats) { } + public record Info(String format, int width, int height) { } + public record Decoded(BufferedImage image, Info info) { } + + public static String cleanPath(String input) { + String value = input == null ? "" : input.strip(); + if (value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")))) { + value = value.substring(1, value.length() - 1).strip(); + } + return value; + } + + public static boolean isHttp(String input) { + String lower = input.toLowerCase(Locale.ROOT); + return lower.startsWith("http://") || lower.startsWith("https://"); + } + + /** Server paths must stay below the game directory, including symlink targets. */ + public static Path serverPath(Path gameDir, String input) throws IOException { + try { + // Reject Windows absolute/UNC/drive paths on Unix servers too. + if (input.isBlank() || input.startsWith("/") || input.startsWith("\\") || input.contains(":")) { + throw new IOException("Server file paths must be relative to the game directory"); + } + for (String part : input.split("[/\\\\]")) { + if (part.equals("..")) throw new IOException("Parent traversal is not allowed for server files"); + } + Path relative = Path.of(input); + Path root = gameDir.toRealPath(); + Path path = root.resolve(relative).normalize(); + if (relative.isAbsolute() || !path.startsWith(root)) throw new IOException("Unsafe server file path"); + Path current = root; + for (Path part : root.relativize(path)) { + current = current.resolve(part); + if (Files.isSymbolicLink(current)) throw new IOException("Server file symlinks are not allowed"); + } + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) && !path.toRealPath().startsWith(root)) { + throw new IOException("Server file path escapes the game directory"); + } + return path; + } catch (InvalidPathException exception) { + throw new IOException("Invalid file path", exception); + } + } + + public static String normalizeFormat(String format) { + String lower = format.toLowerCase(Locale.ROOT); + return lower.equals("jpg") ? "jpeg" : lower; + } + + public static void verifyDeclaration(Info declared, Info actual) throws IOException { + if (!normalizeFormat(declared.format()).equals(normalizeFormat(actual.format())) + || declared.width() != actual.width() || declared.height() != actual.height()) { + throw new IOException("Actual image content does not match the declared metadata"); + } + } + + public static void validateInfo(Info info, long size, Limits limits) throws IOException { + if (size < 1 || size > limits.maxBytes()) throw new IOException("Image file exceeds the allowed byte size"); + if (info.width() < 1 || info.height() < 1) { + throw new IOException("Invalid source image dimensions"); + } + if (limits.formats().stream().noneMatch(x -> normalizeFormat(x).equals(normalizeFormat(info.format())))) { + throw new IOException("Image format is not allowed: " + info.format()); + } + } + + public static byte[] readBounded(InputStream stream, long maxBytes) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[16384]; + int count; + while ((count = stream.read(buffer)) != -1) { + if (Thread.currentThread().isInterrupted()) throw new IOException("Image operation cancelled"); + if ((long) output.size() + count > maxBytes) throw new IOException("Image file exceeds the allowed byte size"); + output.write(buffer, 0, count); + } + if (output.size() == 0) throw new IOException("Image file is empty"); + return output.toByteArray(); + } + + public static Info inspect(byte[] data, Limits limits) throws IOException { + return read(data, limits, false).info(); + } + + public static Decoded decode(byte[] data, Limits limits) throws IOException { + return read(data, limits, true); + } + + private static Decoded read(byte[] data, Limits limits, boolean decode) throws IOException { + if (data.length < 1 || data.length > limits.maxBytes()) throw new IOException("Invalid image file size"); + try (var input = new MemoryCacheImageInputStream(new ByteArrayInputStream(data))) { + var readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) throw new IOException("File content is not a supported image"); + ImageReader reader = readers.next(); + try { + reader.setInput(input, true, true); + Info info = new Info(normalizeFormat(reader.getFormatName()), reader.getWidth(0), reader.getHeight(0)); + validateInfo(info, data.length, limits); // Before allocating decoded pixels. + BufferedImage image = decode ? reader.read(0) : null; + if (decode && (image == null || image.getWidth() != info.width() || image.getHeight() != info.height())) { + throw new IOException("Image dimensions changed while decoding"); + } + return new Decoded(image, info); + } finally { + reader.dispose(); + } + } + } +} diff --git a/src/main/java/space/essem/image2map/network/UploadPayloads.java b/src/main/java/space/essem/image2map/network/UploadPayloads.java new file mode 100644 index 0000000..ea036c1 --- /dev/null +++ b/src/main/java/space/essem/image2map/network/UploadPayloads.java @@ -0,0 +1,102 @@ +package space.essem.image2map.network; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import space.essem.image2map.image.ImageSafety; +import space.essem.image2map.upload.UploadBuffer; + +import java.util.List; +import java.util.UUID; + +/** Common wire types. No client classes may be referenced here. */ +public final class UploadPayloads { + private UploadPayloads() { } + private static CustomPacketPayload.Type id(String name) { + return new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath("image2map", name)); + } + + public static void register() { + PayloadTypeRegistry.serverboundPlay().register(Capabilities.TYPE, Capabilities.CODEC); + PayloadTypeRegistry.serverboundPlay().register(Metadata.TYPE, Metadata.CODEC); + PayloadTypeRegistry.serverboundPlay().register(Chunk.TYPE, Chunk.CODEC); + PayloadTypeRegistry.serverboundPlay().register(Complete.TYPE, Complete.CODEC); + PayloadTypeRegistry.serverboundPlay().register(Cancel.TYPE, Cancel.CODEC); + PayloadTypeRegistry.clientboundPlay().register(Request.TYPE, Request.CODEC); + PayloadTypeRegistry.clientboundPlay().register(Status.TYPE, Status.CODEC); + } + + public record Capabilities(boolean clientUpload) implements CustomPacketPayload { + public static final Type TYPE = id("capabilities_v1"); + public static final StreamCodec CODEC = StreamCodec.of( + (b, p) -> b.writeBoolean(p.clientUpload), b -> new Capabilities(b.readBoolean())); + @Override public Type type() { return TYPE; } + } + + public record Request(UUID requestId, String path, String mode, ImageSafety.Limits limits, int timeoutSeconds) implements CustomPacketPayload { + public static final Type TYPE = id("upload_request_v1"); + public static final StreamCodec CODEC = StreamCodec.of((b, p) -> { + b.writeUUID(p.requestId); + b.writeUtf(p.path, 4096); + b.writeUtf(p.mode, 16); + b.writeLong(p.limits.maxBytes()); + b.writeUtf(String.join(",", p.limits.formats()), 1024); + b.writeInt(p.timeoutSeconds); + }, b -> new Request(b.readUUID(), b.readUtf(4096), b.readUtf(16), + new ImageSafety.Limits(b.readLong(), List.of(b.readUtf(1024).split(","))), b.readInt())); + @Override public Type type() { return TYPE; } + } + + public record Metadata(UUID requestId, String format, long byteSize, int width, int height, String mode) implements CustomPacketPayload { + public static final Type TYPE = id("upload_metadata_v1"); + public static final StreamCodec CODEC = StreamCodec.of((b, p) -> { + b.writeUUID(p.requestId); + b.writeUtf(p.format, 32); + b.writeLong(p.byteSize); + b.writeInt(p.width); + b.writeInt(p.height); + b.writeUtf(p.mode, 16); + }, b -> new Metadata(b.readUUID(), b.readUtf(32), b.readLong(), b.readInt(), b.readInt(), b.readUtf(16))); + public ImageSafety.Info info() { return new ImageSafety.Info(format, width, height); } + @Override public Type type() { return TYPE; } + } + + public record Chunk(UUID requestId, int sequence, byte[] bytes) implements CustomPacketPayload { + public static final Type TYPE = id("upload_chunk_v1"); + public static final StreamCodec CODEC = StreamCodec.of((b, p) -> { + b.writeUUID(p.requestId); + b.writeVarInt(p.sequence); + b.writeByteArray(p.bytes); + }, b -> new Chunk(b.readUUID(), b.readVarInt(), b.readByteArray(UploadBuffer.CHUNK_SIZE))); + @Override public Type type() { return TYPE; } + } + + public record Complete(UUID requestId) implements CustomPacketPayload { + public static final Type TYPE = id("upload_complete_v1"); + public static final StreamCodec CODEC = StreamCodec.of( + (b, p) -> b.writeUUID(p.requestId), b -> new Complete(b.readUUID())); + @Override public Type type() { return TYPE; } + } + + public record Cancel(UUID requestId, String reason) implements CustomPacketPayload { + public static final Type TYPE = id("upload_cancel_v1"); + public static final StreamCodec CODEC = StreamCodec.of((b, p) -> { + b.writeUUID(p.requestId); + b.writeUtf(p.reason, 512); + }, b -> new Cancel(b.readUUID(), b.readUtf(512))); + @Override public Type type() { return TYPE; } + } + + public record Status(UUID requestId, boolean accepted, boolean terminal, String message) implements CustomPacketPayload { + public static final Type TYPE = id("upload_status_v1"); + public static final StreamCodec CODEC = StreamCodec.of((b, p) -> { + b.writeUUID(p.requestId); + b.writeBoolean(p.accepted); + b.writeBoolean(p.terminal); + b.writeUtf(p.message, 512); + }, b -> new Status(b.readUUID(), b.readBoolean(), b.readBoolean(), b.readUtf(512))); + @Override public Type type() { return TYPE; } + } +} diff --git a/src/main/java/space/essem/image2map/renderer/MapRenderer.java b/src/main/java/space/essem/image2map/renderer/MapRenderer.java index d5a55a5..68f06e2 100644 --- a/src/main/java/space/essem/image2map/renderer/MapRenderer.java +++ b/src/main/java/space/essem/image2map/renderer/MapRenderer.java @@ -6,6 +6,7 @@ import java.awt.image.DataBufferByte; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CancellationException; import eu.pb4.mapcanvas.api.core.CanvasColor; import eu.pb4.mapcanvas.api.core.CanvasImage; import eu.pb4.mapcanvas.api.utils.CanvasUtils; @@ -31,8 +32,10 @@ public class MapRenderer { public static CanvasImage render(BufferedImage image, DitherMode mode, int width, int height) { + if (Thread.currentThread().isInterrupted()) throw new CancellationException("Image rendering cancelled"); Image resizedImage = image.getScaledInstance(width, height, Image.SCALE_DEFAULT); BufferedImage resized = convertToBufferedImage(resizedImage); + if (Thread.currentThread().isInterrupted()) throw new CancellationException("Image rendering cancelled"); return switch (mode) { case NONE -> CanvasImage.from(resized); case FLOYD -> CanvasImage.fromWithFloydSteinbergDither(resized); @@ -89,4 +92,4 @@ private static BufferedImage convertToBufferedImage(Image image) { g.dispose(); return newImage; } -} \ No newline at end of file +} diff --git a/src/main/java/space/essem/image2map/upload/ImageTaskState.java b/src/main/java/space/essem/image2map/upload/ImageTaskState.java new file mode 100644 index 0000000..3f0b779 --- /dev/null +++ b/src/main/java/space/essem/image2map/upload/ImageTaskState.java @@ -0,0 +1,48 @@ +package space.essem.image2map.upload; + +import java.io.IOException; +import java.util.UUID; + +/** One instance per connection. Only the server thread mutates it. */ +public final class ImageTaskState { + public enum Phase { IDLE, CLIENT_SELECTING_IMAGE, GETTING_IMAGE, PROCESSING_IMAGE, PREVIEWING } + private Phase phase = Phase.IDLE; + private UUID requestId; + private long deadline; + private long nextAllowed = Long.MIN_VALUE; + + public Phase phase() { return phase; } + public UUID requestId() { return requestId; } + public boolean matches(UUID id) { return requestId != null && requestId.equals(id); } + + public UUID begin(long now) throws IOException { + if (phase != Phase.IDLE || requestId != null) throw new IOException("You already have an image operation or preview open"); + if (now < nextAllowed) throw new IOException("Please wait before starting another image operation"); + requestId = UUID.randomUUID(); + return requestId; + } + + public void transition(Phase next, long now, int timeoutSeconds) { + boolean allowed = switch (phase) { + case IDLE -> requestId != null && (next == Phase.CLIENT_SELECTING_IMAGE || next == Phase.GETTING_IMAGE); + case CLIENT_SELECTING_IMAGE -> next == Phase.GETTING_IMAGE; + case GETTING_IMAGE -> next == Phase.PROCESSING_IMAGE; + case PROCESSING_IMAGE -> next == Phase.PREVIEWING; + case PREVIEWING -> false; + }; + if (!allowed) throw new IllegalStateException("Invalid image task transition: " + phase + " -> " + next); + phase = next; + // Only acquisition can expire. Picking, confirmation, rendering and previews have no deadline. + deadline = next == Phase.GETTING_IMAGE && timeoutSeconds > 0 + ? now + timeoutSeconds * 1_000_000_000L : Long.MAX_VALUE; + } + + public boolean expired(long now) { return phase != Phase.IDLE && now >= deadline; } + + public void finish(long now, int cooldownSeconds) { + phase = Phase.IDLE; + requestId = null; + deadline = 0; + nextAllowed = now + cooldownSeconds * 1_000_000_000L; + } +} diff --git a/src/main/java/space/essem/image2map/upload/ServerImageTasks.java b/src/main/java/space/essem/image2map/upload/ServerImageTasks.java new file mode 100644 index 0000000..3eee232 --- /dev/null +++ b/src/main/java/space/essem/image2map/upload/ServerImageTasks.java @@ -0,0 +1,435 @@ +package space.essem.image2map.upload; + +import eu.pb4.mapcanvas.api.core.CanvasImage; +import eu.pb4.sgui.api.SguiUtils; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.PermissionLevel; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.BundleContents; +import net.minecraft.core.component.DataComponents; +import space.essem.image2map.FabricPermissionBridge; +import space.essem.image2map.Image2Map; +import space.essem.image2map.config.Image2MapConfig; +import space.essem.image2map.gui.PreviewGui; +import space.essem.image2map.image.ImageFetcher; +import space.essem.image2map.image.ImageSafety; +import space.essem.image2map.network.UploadPayloads; +import space.essem.image2map.renderer.MapRenderer; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.Future; + +import static space.essem.image2map.upload.ImageTaskState.Phase.*; + +/** All state changes, permission checks, GUI changes and inventory writes run on the server thread. */ +public final class ServerImageTasks { + public record Options(Image2Map.DitherMode mode, int width, int height, boolean preview, boolean folder) { + /** Preserve the original URL/server-file read and decode deadlines; folders had none. */ + public int acquisitionTimeoutSeconds() { return folder ? 0 : preview ? 30 : 20; } + } + private final Map sessions = new IdentityHashMap<>(); + private final Map cooldowns = new HashMap<>(); + private ExecutorService workers; + private ImageFetcher fetcher; + + private static final class Session { + final ImageTaskState state = new ImageTaskState(); + boolean clientUpload; + long nextRejection; + Job job; + } + + private static final class Job { + final UUID id; + final CommandSourceStack source; + final ServerPlayer player; + final String input; + final Options options; + final ImageFetcher.Resources resources = new ImageFetcher.Resources(); + boolean clientUpload; + UploadPayloads.Metadata metadata; + UploadBuffer buffer; + PreviewGui preview; + + Job(UUID id, CommandSourceStack source, ServerPlayer player, String input, Options options) { + this.id = id; + this.source = source; + this.player = player; + this.input = input; + this.options = options; + } + } + + private Image2MapConfig config() { return Image2Map.CONFIG; } + + public Future renderPreview(BufferedImage image, Image2Map.DitherMode mode, int width, int height) { + return workers.submit(() -> MapRenderer.render(image, mode, width, height)); + } + + public void register() { + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + workers = new ThreadPoolExecutor(2, 2, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(16), + Thread.ofPlatform().daemon().name("image2map-worker-", 0).factory(), new ThreadPoolExecutor.AbortPolicy()); + fetcher = new ImageFetcher(config().networkTimeout); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + for (Session session : new ArrayList<>(sessions.values())) { + if (session.job != null) dispose(session); + } + sessions.clear(); + cooldowns.clear(); + if (workers != null) workers.shutdownNow(); + if (fetcher != null) fetcher.close(); + }); + ServerPlayConnectionEvents.INIT.register((handler, server) -> sessions.put(handler.player, new Session())); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + Session session = sessions.remove(handler.player); + if (session != null && session.job != null) dispose(session); + }); + ServerTickEvents.END_SERVER_TICK.register(server -> { + long now = System.nanoTime(); + cooldowns.entrySet().removeIf(entry -> now >= entry.getValue()); + for (Session session : new ArrayList<>(sessions.values())) { + if (session.job == null) continue; + if (session.state.expired(now)) fail(session, "Image operation timed out"); + else if (!hasPermission(session.job)) fail(session, "Image2Map permission was revoked"); + else if (session.state.phase() == PREVIEWING && (session.job.preview == null || !session.job.preview.isOpen())) { + finish(session, true, "Preview closed"); + } + } + }); + ServerPlayNetworking.registerGlobalReceiver(UploadPayloads.Capabilities.TYPE, (payload, context) -> { + Session session = sessions.get(context.player()); + if (session != null) { + session.clientUpload = payload.clientUpload(); + if (!payload.clientUpload() && session.job != null && session.job.clientUpload) { + fail(session, "Client no longer supports image uploads"); + } + } + }); + ServerPlayNetworking.registerGlobalReceiver(UploadPayloads.Metadata.TYPE, (payload, context) -> metadata(context.player(), payload)); + ServerPlayNetworking.registerGlobalReceiver(UploadPayloads.Chunk.TYPE, (payload, context) -> chunk(context.player(), payload)); + ServerPlayNetworking.registerGlobalReceiver(UploadPayloads.Complete.TYPE, (payload, context) -> complete(context.player(), payload)); + ServerPlayNetworking.registerGlobalReceiver(UploadPayloads.Cancel.TYPE, (payload, context) -> { + Session session = matching(context.player(), payload.requestId()); + if (session != null && session.job.clientUpload) fail(session, "Client cancelled: " + payload.reason()); + }); + } + + public int start(CommandSourceStack source, ServerPlayer player, String path, Options options) { + Session session = sessions.computeIfAbsent(player, ignored -> new Session()); + UUID id; + try { + if (System.nanoTime() < cooldowns.getOrDefault(player.getUUID(), Long.MIN_VALUE)) { + throw new IOException("Please wait before starting another image operation"); + } + id = session.state.begin(System.nanoTime()); + } catch (IOException exception) { + source.sendFailure(Component.literal(exception.getMessage())); + return 0; + } + Job job = new Job(id, source, player, ImageSafety.cleanPath(path), options); + session.job = job; + try { + if (!hasPermission(job)) throw new IOException("You do not have permission to use this image operation"); + if (options.folder()) { + if (!config().allowServerLocalFiles) throw new IOException("Server local files are disabled"); + Path folder = ImageSafety.serverPath(FabricLoader.getInstance().getGameDir(), job.input); + if (!Files.isDirectory(folder, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Server image folder does not exist"); + transition(session, GETTING_IMAGE); + loadFolder(session, job, folder); + } else if (ImageSafety.isHttp(job.input)) { + transition(session, GETTING_IMAGE); + submit(session, job, () -> loaded(session, job, fetcher.download(job.input, config().imageLimits(), job.resources))); + } else { + Path serverFile = null; + if (config().allowServerLocalFiles && !job.input.isEmpty()) { + try { serverFile = ImageSafety.serverPath(FabricLoader.getInstance().getGameDir(), job.input); } + catch (IOException ignored) { /* Unsafe server paths can still be selected on the client. */ } + } + if (serverFile != null && Files.exists(serverFile, LinkOption.NOFOLLOW_LINKS)) { + Path file = serverFile; + transition(session, GETTING_IMAGE); + submit(session, job, () -> loaded(session, job, ImageFetcher.file(file, config().imageLimits(), job.resources))); + } else { + if (!config().allowClientUploadFiles) throw new IOException("Client image uploads are disabled on this server"); + if (!session.clientUpload || !ServerPlayNetworking.canSend(player, UploadPayloads.Request.TYPE) + || !ServerPlayNetworking.canSend(player, UploadPayloads.Status.TYPE)) { + throw new IOException("Your client does not support local image uploads; provide an HTTP/HTTPS URL"); + } + job.clientUpload = true; + transition(session, CLIENT_SELECTING_IMAGE); + ServerPlayNetworking.send(player, new UploadPayloads.Request(id, job.input, options.mode().name(), + config().imageLimits(), config().networkTimeout)); + } + } + source.sendSuccess(() -> Component.literal(job.clientUpload ? "Select and confirm an image on your client..." : "Getting image..."), false); + return 1; + } catch (Exception exception) { + fail(session, message(exception)); + return 0; + } + } + + private boolean hasPermission(Job job) { + ServerPlayer player = job.player; + return FabricPermissionBridge.checkPermission(player, permission("use"), PermissionLevel.byId(config().minPermLevel)) + && (job.options.folder() + ? FabricPermissionBridge.checkPermission(player, permission("createfolder"), PermissionLevel.ADMINS) + : FabricPermissionBridge.checkPermission(player, permission(job.options.preview() ? "preview" : "create"), true)); + } + + private static Identifier permission(String name) { return Identifier.fromNamespaceAndPath("image2map", name); } + private void transition(Session session, ImageTaskState.Phase phase) { + Job job = session.job; + int timeout = phase == GETTING_IMAGE && !job.options.folder() + ? job.clientUpload ? config().networkTimeout : job.options.acquisitionTimeoutSeconds() : 0; + session.state.transition(phase, System.nanoTime(), timeout); + } + + private Session matching(ServerPlayer player, UUID id) { + Session session = sessions.get(player); + if (session == null || session.job == null || !session.state.matches(id)) { + // Reject stale/replayed requests without aborting a different active task or amplifying floods. + long now = System.nanoTime(); + if (session != null && now >= session.nextRejection && ServerPlayNetworking.canSend(player, UploadPayloads.Status.TYPE)) { + session.nextRejection = now + 1_000_000_000L; + ServerPlayNetworking.send(player, new UploadPayloads.Status(id, false, true, "Unknown image requestId")); + } + return null; + } + if (!session.job.clientUpload || !config().allowClientUploadFiles || !hasPermission(session.job)) { + fail(session, "Client upload is not allowed"); + return null; + } + if (session.state.expired(System.nanoTime())) { + fail(session, "Image operation timed out"); + return null; + } + return session; + } + + private void metadata(ServerPlayer player, UploadPayloads.Metadata payload) { + Session session = matching(player, payload.requestId()); + if (session == null) return; + try { + if (session.state.phase() != CLIENT_SELECTING_IMAGE) throw new IOException("Image metadata is not expected in this state"); + ImageSafety.validateInfo(payload.info(), payload.byteSize(), config().imageLimits()); + if (!payload.mode().equals(session.job.options.mode().name())) throw new IOException("Dither mode does not match the request"); + session.job.buffer = new UploadBuffer(payload.byteSize(), config().imageLimits().maxBytes()); + session.job.metadata = payload; + transition(session, GETTING_IMAGE); + ServerPlayNetworking.send(player, new UploadPayloads.Status(payload.requestId(), true, false, "Ready to receive image")); + } catch (Exception exception) { fail(session, message(exception)); } + } + + private void chunk(ServerPlayer player, UploadPayloads.Chunk payload) { + Session session = matching(player, payload.requestId()); + if (session == null) return; + try { + if (session.state.phase() != GETTING_IMAGE || session.job.buffer == null) throw new IOException("Image chunks are not expected in this state"); + session.job.buffer.append(payload.sequence(), payload.bytes()); + } catch (Exception exception) { fail(session, message(exception)); } + } + + private void complete(ServerPlayer player, UploadPayloads.Complete payload) { + Session session = matching(player, payload.requestId()); + if (session == null) return; + try { + Job job = session.job; + if (session.state.phase() != GETTING_IMAGE || job.buffer == null) throw new IOException("Image completion is not expected in this state"); + byte[] data = job.buffer.complete(); + job.buffer = null; + loaded(session, job, data); + } catch (Exception exception) { fail(session, message(exception)); } + } + + private boolean active(Session session, Job job) { + return sessions.get(job.player) == session && session.job == job && session.state.matches(job.id) + && !job.player.hasDisconnected() && !session.state.expired(System.nanoTime()); + } + + private void loaded(Session session, Job job, byte[] data) { + job.source.getServer().execute(() -> { + if (!active(session, job)) return; + submit(session, job, () -> { + // Decoding was part of getImage's timeout in the original implementation. + var decoded = ImageSafety.decode(data, config().imageLimits()); + if (job.metadata != null) ImageSafety.verifyDeclaration(job.metadata.info(), decoded.info()); + job.source.getServer().execute(() -> process(session, job, decoded.image())); + }); + }); + } + + private void process(Session session, Job job, BufferedImage image) { + if (!active(session, job)) { image.flush(); return; } + try { + if (!hasPermission(job)) throw new IOException("Image2Map permission was revoked"); + transition(session, PROCESSING_IMAGE); + job.source.sendSuccess(() -> Component.literal("Converting into maps..."), false); + int[] size = outputSize(image, job.options); + if (job.options.preview()) { + transition(session, PREVIEWING); + job.preview = new PreviewGui(job.player, image, job.input, job.options.mode(), size[0], size[1], + () -> { if (active(session, job)) finish(session, true, "Preview closed"); }); + status(job, true, true, "Image received; preview opened"); + } else { + submit(session, job, () -> { + CanvasImage rendered; + try { rendered = MapRenderer.render(image, job.options.mode(), size[0], size[1]); } + finally { image.flush(); } + job.source.getServer().execute(() -> { + if (!active(session, job)) return; + try { + if (!hasPermission(job)) throw new IOException("Image2Map permission was revoked"); + Image2Map.giveToPlayer(job.player, MapRenderer.toVanillaItems(rendered, job.player.level(), job.input), job.input, size[0], size[1]); + finish(session, true, "Done!"); + } catch (Exception exception) { fail(session, message(exception)); } + }); + }); + } + } catch (Exception exception) { image.flush(); fail(session, message(exception)); } + } + + private int[] outputSize(BufferedImage image, Options options) throws IOException { + int width = options.width(), height = options.height(); + if (width == 0 || height == 0) { + double scale = Math.min(1, Math.min(config().imageMaxWidthHeight / (double) image.getWidth(), + config().imageMaxWidthHeight / (double) image.getHeight())); + width = Math.max(1, (int) (image.getWidth() * scale)); + height = Math.max(1, (int) (image.getHeight() * scale)); + } + if (width < 1 || height < 1 || width > config().imageMaxWidthHeight || height > config().imageMaxWidthHeight) { + throw new IOException("Map output dimensions exceed the configured limit"); + } + return new int[] { width, height }; + } + + private void loadFolder(Session session, Job job, Path folder) { + submit(session, job, () -> { + // Keep the existing bulk command under the same per-player task guard. + ArrayList files = new ArrayList<>(); + try (var paths = Files.walk(folder)) { + var iterator = paths.filter(p -> Files.isRegularFile(p, LinkOption.NOFOLLOW_LINKS)).iterator(); + while (iterator.hasNext()) { + if (Thread.currentThread().isInterrupted()) throw new IOException("Folder operation cancelled"); + files.add(iterator.next()); + } + } + job.source.getServer().execute(() -> { + if (!active(session, job)) return; + transition(session, PROCESSING_IMAGE); + submit(session, job, () -> { + record Rendered(CanvasImage image, int width, int height) { } + var images = new ArrayList(); + for (Path file : files) { + if (Thread.currentThread().isInterrupted()) throw new IOException("Folder operation cancelled"); + Path safe = ImageSafety.serverPath(FabricLoader.getInstance().getGameDir(), + FabricLoader.getInstance().getGameDir().toRealPath().relativize(file).toString()); + ImageSafety.Decoded decoded; + int[] size; + try { + decoded = ImageSafety.decode(ImageFetcher.file(safe, config().imageLimits(), job.resources), config().imageLimits()); + size = outputSize(decoded.image(), job.options); + } catch (IOException exception) { + Image2Map.LOGGER.debug("Skipping invalid image in folder: {}", file, exception); + continue; + } + images.add(new Rendered(MapRenderer.render(decoded.image(), job.options.mode(), size[0], size[1]), size[0], size[1])); + decoded.image().flush(); + } + job.source.getServer().execute(() -> { + if (!active(session, job)) return; + try { + if (!hasPermission(job)) throw new IOException("Image2Map permission was revoked"); + if (images.isEmpty()) throw new IOException("Folder contains no valid images"); + var items = images.stream().map(image -> Image2Map.toSingleStack( + MapRenderer.toVanillaItems(image.image(), job.player.level(), job.input), job.input, image.width(), image.height())).toList(); + ItemStack bundle = new ItemStack(Items.BUNDLE); + bundle.set(DataComponents.BUNDLE_CONTENTS, new BundleContents(items)); + job.player.addItem(bundle); + finish(session, true, "Done!"); + } catch (Exception exception) { fail(session, message(exception)); } + }); + }); + }); + }); + } + + @FunctionalInterface private interface Work { void run() throws Exception; } + private void submit(Session session, Job job, Work work) { + try { + job.resources.track(workers.submit(() -> { + try { work.run(); } + catch (Exception exception) { + job.source.getServer().execute(() -> { if (active(session, job)) fail(session, message(exception)); }); + } + })); + } catch (RuntimeException exception) { fail(session, "Image worker queue is full; please try again later"); } + } + + private static String message(Throwable exception) { + String value = exception.getMessage(); + if (value == null || value.isBlank()) value = exception.getClass().getSimpleName(); + return value.length() > 480 ? value.substring(0, 480) : value; + } + + private void status(Job job, boolean success, boolean terminal, String message) { + if (job.clientUpload && !job.player.hasDisconnected() && ServerPlayNetworking.canSend(job.player, UploadPayloads.Status.TYPE)) { + ServerPlayNetworking.send(job.player, new UploadPayloads.Status(job.id, success, terminal, + message.length() > 512 ? message.substring(0, 512) : message)); + } + } + + private void fail(Session session, String reason) { finish(session, false, reason); } + private void finish(Session session, boolean success, String message) { + Job job = session.job; + if (job == null) return; + try { + status(job, success, true, message); + // Client upload status is displayed by the client; avoid duplicate error messages. + if (!job.clientUpload || success) { + job.source.sendSuccess(() -> Component.literal(message).withStyle(success ? ChatFormatting.GREEN : ChatFormatting.RED), false); + } + } finally { dispose(session); } + } + + private void dispose(Session session) { + Job job = session.job; + session.job = null; // Invalidate callbacks before closing a preview. + session.state.finish(System.nanoTime(), config().imageOperationCooldownSeconds); + cooldowns.put(job.player.getUUID(), System.nanoTime() + config().imageOperationCooldownSeconds * 1_000_000_000L); + job.resources.close(); + job.buffer = null; + PreviewGui preview = job.preview; + // A GUI constructor can fail after opening its base GUI but before returning its instance. + if (preview == null && job.options.preview() && SguiUtils.getCurrentGui(job.player) instanceof PreviewGui opened) preview = opened; + if (preview != null && preview.isOpen()) { + try { preview.close(); } + catch (RuntimeException exception) { Image2Map.LOGGER.warn("Failed to close an image preview", exception); } + } + } +} diff --git a/src/main/java/space/essem/image2map/upload/UploadBuffer.java b/src/main/java/space/essem/image2map/upload/UploadBuffer.java new file mode 100644 index 0000000..8480252 --- /dev/null +++ b/src/main/java/space/essem/image2map/upload/UploadBuffer.java @@ -0,0 +1,34 @@ +package space.essem.image2map.upload; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** Bounded, ordered reassembly. Metadata never allocates the declared file size. */ +public final class UploadBuffer { + public static final int CHUNK_SIZE = 16 * 1024; + private final long expectedSize; + private final long maxSize; + private final ByteArrayOutputStream data = new ByteArrayOutputStream(); + private int sequence; + + public UploadBuffer(long expectedSize, long maxSize) throws IOException { + if (expectedSize < 1 || expectedSize > maxSize) throw new IOException("Invalid declared image size"); + this.expectedSize = expectedSize; + this.maxSize = maxSize; + } + + public void append(int index, byte[] bytes) throws IOException { + if (index != sequence || bytes.length < 1 || bytes.length > CHUNK_SIZE) { + throw new IOException("Invalid or out-of-order image chunk"); + } + long size = (long) data.size() + bytes.length; + if (size > maxSize || size > expectedSize) throw new IOException("Uploaded image exceeds its declared size"); + data.writeBytes(bytes); + sequence++; + } + + public byte[] complete() throws IOException { + if (data.size() != expectedSize) throw new IOException("Uploaded image size does not match its declaration"); + return data.toByteArray(); + } +} diff --git a/src/main/resources/assets/image2map/lang/en_us.json b/src/main/resources/assets/image2map/lang/en_us.json new file mode 100644 index 0000000..05e09ec --- /dev/null +++ b/src/main/resources/assets/image2map/lang/en_us.json @@ -0,0 +1,10 @@ +{ + "image2map.upload.title": "Image2Map upload", + "image2map.upload.selecting": "Choose an image in the file dialog", + "image2map.upload.confirm.title": "Upload this local image?", + "image2map.upload.confirm.message": "This image will be uploaded to the server: %s\nConfirm upload?", + "image2map.upload.confirm.yes": "Upload image", + "image2map.upload.reading": "Checking the selected image...", + "image2map.upload.progress": "Uploading image: %s%%", + "image2map.upload.processing": "Server is processing the image..." +} diff --git a/src/main/resources/assets/image2map/lang/zh_cn.json b/src/main/resources/assets/image2map/lang/zh_cn.json new file mode 100644 index 0000000..497cfd2 --- /dev/null +++ b/src/main/resources/assets/image2map/lang/zh_cn.json @@ -0,0 +1,10 @@ +{ + "image2map.upload.title": "Image2Map 图片上传", + "image2map.upload.selecting": "请在文件对话框中选择图片", + "image2map.upload.confirm.title": "确认上传这张本地图片?", + "image2map.upload.confirm.message": "将把此图片上传到服务器:%s\n确定上传吗?", + "image2map.upload.confirm.yes": "上传图片", + "image2map.upload.reading": "正在检查所选图片…", + "image2map.upload.progress": "正在上传图片:%s%%", + "image2map.upload.processing": "服务端正在处理图片…" +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index bf38090..993c8f0 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -22,6 +22,9 @@ "main": [ "space.essem.image2map.Image2Map" ], + "client": [ + "space.essem.image2map.client.Image2MapClient" + ], "preLaunch": [ "space.essem.image2map.CardboardWarning", "space.essem.image2map.ImageIoBootstrap" diff --git a/src/securityTest/java/space/essem/image2map/SecurityRegressionTest.java b/src/securityTest/java/space/essem/image2map/SecurityRegressionTest.java new file mode 100644 index 0000000..785eeee --- /dev/null +++ b/src/securityTest/java/space/essem/image2map/SecurityRegressionTest.java @@ -0,0 +1,385 @@ +package space.essem.image2map; + +import com.google.gson.JsonParser; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.tree.CommandNode; +import com.sun.net.httpserver.HttpServer; +import io.netty.buffer.Unpooled; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import space.essem.image2map.config.ConfigMigration; +import space.essem.image2map.config.Image2MapConfig; +import space.essem.image2map.image.ImageFetcher; +import space.essem.image2map.image.ImageSafety; +import space.essem.image2map.upload.ImageTaskState; +import space.essem.image2map.upload.ServerImageTasks; +import space.essem.image2map.upload.UploadBuffer; +import space.essem.image2map.network.UploadPayloads; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.FutureTask; +import java.util.concurrent.Executors; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static space.essem.image2map.upload.ImageTaskState.Phase.*; + +/** Run via gradlew securityTest/check; failures throw AssertionError without requiring a game. */ +public final class SecurityRegressionTest { + private static int assertions; + private static final ImageSafety.Limits LIMITS = new ImageSafety.Limits(1024 * 1024, List.of("png", "jpeg", "gif", "bmp")); + + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]); + Files.createDirectories(root); + configMigration(); + paths(root); + images(root); + uploads(); + lifecycle(); + cancellation(); + commands(); + protocol(); + http(); + System.out.println("Image2Map security regression checks passed: " + assertions); + } + + private static void configMigration() throws Exception { + var old = JsonParser.parseString("{\"allowLocalFiles\":true,\"maxSize\":1234,\"minPermLevel\":1}").getAsJsonObject(); + ConfigMigration.migrate(old); + check(old.get("version").getAsInt() == 2, "Legacy version migration"); + check(!old.has("maxSize") && !old.has("allowLocalFiles"), "Legacy names removed"); + Image2MapConfig config = Image2MapConfig.fromJson(old.toString()); + check(config.allowServerLocalFiles && config.imageMaxWidthHeight == 1234 && config.minPermLevel == 1, "Legacy values preserved"); + check(config.allowClientUploadFiles && config.networkTimeout == 30 && config.imageOperationCooldownSeconds == 5, "New defaults"); + check(config.imageLimits().maxBytes() == 20 * 1024 * 1024L, "Default image size is 20 MiB"); + check(config.imageLimits().maxBytes() == config.imageFileMaxSize * 1024L, "KiB conversion"); + check(Image2MapConfig.fromJson("{\"version\":2,\"imageMaxWidthHeight\":99}").imageMaxWidthHeight == 99, "Version 2 preserved"); + rejects(() -> Image2MapConfig.fromJson("{\"version\":3}"), "Unsupported config version"); + rejects(() -> Image2MapConfig.fromJson("{\"version\":2,\"networkTimeout\":0}"), "Invalid timeout"); + rejects(() -> Image2MapConfig.fromJson("{\"version\":2,\"imageFileMaxSize\":-1}"), "Invalid file budget"); + rejects(() -> Image2MapConfig.fromJson("{\"version\":2,\"allowedImageFormats\":null}"), "Missing format limits"); + var mixed = Image2MapConfig.fromJson("{\"maxSize\":111,\"imageMaxWidthHeight\":222}"); + check(mixed.imageMaxWidthHeight == 222, "New field takes precedence during migration"); + } + + private static void paths(Path root) throws Exception { + check(ImageSafety.cleanPath(" \"some image.png\" ").equals("some image.png"), "Quoted path"); + check(ImageSafety.cleanPath("''").isEmpty() && ImageSafety.cleanPath(null).isEmpty(), "Empty picker path"); + check(ImageSafety.cleanPath("'a b.png'").equals("a b.png"), "Single quotes"); + check(ImageSafety.isHttp("HTTPS://example.com/x") && !ImageSafety.isHttp("file:///x"), "Only HTTP/HTTPS"); + Path safe = ImageSafety.serverPath(root, "images/a.png"); + check(safe.startsWith(root.toRealPath()), "Relative server path confined"); + for (String input : List.of("../x", "images/../../x", "images\\..\\x", "/etc/passwd", "C:\\x", "C:x", "\\\\host\\share", "", "a\u0000b")) { + rejects(() -> ImageSafety.serverPath(root, input), "Reject server traversal or absolute path " + input); + } + Path link = root.resolve("link"); + try { + if (!Files.exists(link)) Files.createSymbolicLink(link, root.toRealPath()); + rejects(() -> ImageSafety.serverPath(root, "link/x.png"), "Reject server symlink"); + } catch (IOException | UnsupportedOperationException exception) { + System.out.println("Symlink check skipped: platform does not allow symlink creation"); + } + } + + private static byte[] png(int width, int height) throws IOException { + var bytes = new ByteArrayOutputStream(); + ImageIO.write(new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB), "png", bytes); + return bytes.toByteArray(); + } + + private static void images(Path root) throws Exception { + byte[] bytes = png(40, 25); + var decoded = ImageSafety.decode(bytes, LIMITS); + check(decoded.info().format().equals("png") && decoded.image().getWidth() == 40 && decoded.image().getHeight() == 25, "Actual image format/dimensions"); + ImageSafety.verifyDeclaration(new ImageSafety.Info("PNG", 40, 25), decoded.info()); + rejects(() -> ImageSafety.verifyDeclaration(new ImageSafety.Info("png", 40, 26), decoded.info()), "Spoofed dimension metadata"); + rejects(() -> ImageSafety.verifyDeclaration(new ImageSafety.Info("jpeg", 40, 25), decoded.info()), "Spoofed format metadata"); + rejects(() -> ImageSafety.decode("not an image".getBytes(), LIMITS), "Non-image content"); + rejects(() -> ImageSafety.decode(bytes, new ImageSafety.Limits(1, List.of("png"))), "Encoded byte limit"); + rejects(() -> ImageSafety.decode(bytes, new ImageSafety.Limits(100000, List.of("jpeg"))), "Content format allowlist"); + rejects(() -> ImageSafety.decode(Arrays.copyOf(bytes, 24), LIMITS), "Truncated image"); + var wide = ImageSafety.decode(png(20000, 1), LIMITS); + check(wide.info().width() == 20000, "Wide source image accepted within file size limit"); + wide.image().flush(); + ImageSafety.validateInfo(new ImageSafety.Info("png", 100000, 100000), bytes.length, LIMITS); + rejects(() -> ImageSafety.validateInfo(new ImageSafety.Info("png", 0, 25), bytes.length, LIMITS), "Invalid source dimensions"); + check(ImageSafety.normalizeFormat("JPG").equals("jpeg"), "JPEG aliases"); + Path fakeExtension = root.resolve("actually-png.txt"); + Files.write(fakeExtension, bytes); + try (var resources = new ImageFetcher.Resources()) { + check(ImageSafety.decode(ImageFetcher.file(fakeExtension, LIMITS, resources), LIMITS).info().format().equals("png"), "Extension not trusted"); + } + try (var resources = new ImageFetcher.Resources()) { + rejects(() -> ImageFetcher.file(root, LIMITS, resources), "Directory upload rejected"); + } + check(Arrays.equals(bytes, ImageSafety.readBounded(new ByteArrayInputStream(bytes), bytes.length)), "Stream exact limit"); + rejects(() -> ImageSafety.readBounded(new ByteArrayInputStream(bytes), bytes.length - 1), "Stream oversized without Content-Length"); + rejects(() -> ImageSafety.readBounded(new ByteArrayInputStream(new byte[0]), 100), "Empty stream"); + } + + private static void uploads() throws Exception { + byte[] bytes = new byte[UploadBuffer.CHUNK_SIZE + 3]; + UploadBuffer upload = new UploadBuffer(bytes.length, bytes.length); + upload.append(0, Arrays.copyOfRange(bytes, 0, UploadBuffer.CHUNK_SIZE)); + upload.append(1, Arrays.copyOfRange(bytes, UploadBuffer.CHUNK_SIZE, bytes.length)); + check(Arrays.equals(bytes, upload.complete()), "16KiB multi-chunk reassembly"); + rejects(() -> new UploadBuffer(0, 100), "Empty metadata"); + rejects(() -> new UploadBuffer(101, 100), "Oversized metadata"); + rejects(() -> new UploadBuffer(10, 100).append(1, new byte[5]), "Out-of-order chunk"); + rejects(() -> new UploadBuffer(10, 100).append(0, new byte[0]), "Empty chunk"); + rejects(() -> new UploadBuffer(100000, 100000).append(0, new byte[UploadBuffer.CHUNK_SIZE + 1]), "Chunk limit"); + rejects(() -> new UploadBuffer(10, 100).append(0, new byte[11]), "Declared size exceeded"); + UploadBuffer replay = new UploadBuffer(10, 100); + replay.append(0, new byte[5]); + rejects(() -> replay.append(0, new byte[5]), "Duplicate chunk"); + rejects(replay::complete, "Incomplete upload"); + } + + private static void lifecycle() throws Exception { + ImageTaskState state = new ImageTaskState(); + check(state.phase() == IDLE, "New connection starts idle"); + UUID id = state.begin(0); + state.transition(CLIENT_SELECTING_IMAGE, 0, 30); + check(state.matches(id) && !state.matches(UUID.randomUUID()), "RequestId matching"); + long hoursLater = TimeUnit.HOURS.toNanos(8); + check(!state.expired(hoursLater), "Selection and confirmation can take hours"); + rejects(() -> state.begin(1), "Concurrent task blocked"); + rejects(() -> state.begin(hoursLater), "Long selection still reserves the task"); + rejects(() -> state.transition(PROCESSING_IMAGE, 1, 30), "Cannot bypass metadata acceptance"); + state.transition(GETTING_IMAGE, hoursLater, 30); + check(!state.expired(hoursLater + TimeUnit.SECONDS.toNanos(30) - 1) + && state.expired(hoursLater + TimeUnit.SECONDS.toNanos(30)), "Acquisition timing starts after selection"); + state.transition(PROCESSING_IMAGE, hoursLater + 1, 30); + check(!state.expired(hoursLater * 2), "Rendering has no acquisition or network deadline"); + state.transition(PREVIEWING, hoursLater + 2, 30); + rejects(() -> state.begin(4), "Preview reserves task"); + check(!state.expired(100000000000L), "Idle preview remains open"); + state.finish(10, 5); + check(state.phase() == IDLE && !state.matches(id), "Preview close invalidates requestId"); + rejects(() -> state.begin(5000000009L), "Cooldown enforced"); + UUID next = state.begin(5000000010L); + check(!id.equals(next), "RequestId regenerated"); + state.transition(GETTING_IMAGE, 5000000010L, 30); + check(!state.expired(35000000009L) && state.expired(35000000010L), "Upload acquisition deadline enforced"); + state.finish(35000000010L, 0); + state.begin(35000000010L); + state.transition(GETTING_IMAGE, 35000000010L, 30); + state.finish(35000000011L, 0); + check(state.phase() == IDLE, "Timeout/failure permits a new job"); + for (var phase : List.of(CLIENT_SELECTING_IMAGE, GETTING_IMAGE, PROCESSING_IMAGE, PREVIEWING)) { + state.begin(100000000000L); + if (phase == CLIENT_SELECTING_IMAGE) state.transition(phase, 100000000000L, 30); + else { + state.transition(GETTING_IMAGE, 100000000000L, 30); + if (phase == PROCESSING_IMAGE || phase == PREVIEWING) state.transition(PROCESSING_IMAGE, 100000000000L, 30); + if (phase == PREVIEWING) state.transition(PREVIEWING, 100000000000L, 30); + } + state.finish(100000000000L, 0); + check(state.phase() == IDLE && state.requestId() == null, "Cancel/disconnect resets " + phase); + } + for (boolean preview : List.of(false, true)) { + var options = new ServerImageTasks.Options(Image2Map.DitherMode.NONE, 0, 0, preview, false); + int seconds = options.acquisitionTimeoutSeconds(); + check(seconds == (preview ? 30 : 20), "Original acquisition deadline for " + (preview ? "preview" : "create")); + var acquisition = new ImageTaskState(); + acquisition.begin(0); + acquisition.transition(GETTING_IMAGE, 0, seconds); + long boundary = TimeUnit.SECONDS.toNanos(seconds); + check(!acquisition.expired(boundary - 1) && acquisition.expired(boundary), "Reading and decoding share the original deadline"); + acquisition.transition(PROCESSING_IMAGE, boundary - 1, seconds); + check(!acquisition.expired(hoursLater), "Rendering can exceed the original acquisition deadline"); + } + var folder = new ServerImageTasks.Options(Image2Map.DitherMode.NONE, 0, 0, false, true); + check(folder.acquisitionTimeoutSeconds() == 0, "Folder retains its original lack of acquisition timeout"); + var folderState = new ImageTaskState(); + folderState.begin(0); + folderState.transition(GETTING_IMAGE, 0, folder.acquisitionTimeoutSeconds()); + check(!folderState.expired(hoursLater), "Folder traversal has no deadline"); + folderState.transition(PROCESSING_IMAGE, hoursLater, 30); + check(!folderState.expired(hoursLater * 2), "Folder decoding and rendering have no deadline"); + } + + private static void cancellation() throws Exception { + AtomicBoolean closed = new AtomicBoolean(); + var stream = new ByteArrayInputStream(new byte[] { 1 }) { + @Override public void close() { closed.set(true); } + }; + var future = new FutureTask<>(() -> null); + var resources = new ImageFetcher.Resources(); + resources.track(stream); + resources.track(future); + resources.close(); + check(closed.get() && future.isCancelled(), "Abort releases stream and worker"); + var late = new FutureTask<>(() -> null); + resources.track(late); + check(late.isCancelled(), "Late worker cannot revive aborted task"); + rejects(() -> resources.track(new ByteArrayInputStream(new byte[] { 1 })), "Late stream closed after abort"); + resources.close(); + } + + private static CommandNode unrestricted(CommandNode node) { + var copy = node.createBuilder().requires(source -> true).build(); + for (var child : node.getChildren()) copy.addChild(unrestricted(child)); + return copy; + } + + private static void commands() throws Exception { + net.minecraft.SharedConstants.tryDetectVersion(); + net.minecraft.server.Bootstrap.bootStrap(); + var dispatcher = new CommandDispatcher(); + // Parsing exercises the real registered syntax; permission behavior needs a live player. + dispatcher.getRoot().addChild(unrestricted(ImageCommands.root(new Image2MapConfig()).build())); + for (String input : List.of("image2map create", "image2map create none", "image2map create dither", + "image2map create 128 256 none", "image2map preview", "image2map preview https://example.com/a.png", + "image2map create none https://example.com/a.png", "image2map create 128 256 dither https://example.com/a.png", + "image2map create none \"C:\\my images\\a.png\"", "image2map create-folder none images")) { + var parsed = dispatcher.parse(input, null); + check(!parsed.getReader().canRead() && parsed.getExceptions().isEmpty() && parsed.getContext().build(input).getCommand() != null, + "Executable command syntax: " + input); + } + String input = "image2map create none \"C:\\my images\\a.png\""; + var parsed = dispatcher.parse(input, null).getContext().build(input); + check(ImageSafety.cleanPath(StringArgumentType.getString(parsed, "path")).equals("C:\\my images\\a.png"), "Greedy quoted Windows path preserved"); + var oversized = dispatcher.parse("image2map create 99999 128 none", null).getContext().build("image2map create 99999 128 none"); + rejects(() -> Image2Map.DitherMode.fromString(StringArgumentType.getString(oversized, "mode")), + "Out-of-range dimensions cannot become a valid mode-only command"); + check(dispatcher.parse("image2map create-folder none", null).getContext().build("image2map create-folder none").getCommand() == null, + "Folder command still requires a server path"); + } + + private static T roundTrip(StreamCodec codec, T value) { + FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer()); + try { + codec.encode(buffer, value); + T decoded = codec.decode(buffer); + check(!buffer.isReadable(), "Payload fully consumed"); + return decoded; + } finally { buffer.release(); } + } + + private static void protocol() throws Exception { + UUID id = UUID.randomUUID(); + var caps = new UploadPayloads.Capabilities(false); + check(roundTrip(UploadPayloads.Capabilities.CODEC, caps).equals(caps), "Unsupported capability preserved"); + var request = new UploadPayloads.Request(id, "C:\\my image.png", "NONE", LIMITS, 30); + check(roundTrip(UploadPayloads.Request.CODEC, request).equals(request), "Request round trip"); + var metadata = new UploadPayloads.Metadata(id, "png", 1234, 40, 25, "NONE"); + check(roundTrip(UploadPayloads.Metadata.CODEC, metadata).equals(metadata), "Metadata round trip"); + var chunk = roundTrip(UploadPayloads.Chunk.CODEC, new UploadPayloads.Chunk(id, 2, new byte[UploadBuffer.CHUNK_SIZE])); + check(chunk.requestId().equals(id) && chunk.sequence() == 2 && chunk.bytes().length == UploadBuffer.CHUNK_SIZE, "Chunk round trip and identity"); + var complete = new UploadPayloads.Complete(id); + check(roundTrip(UploadPayloads.Complete.CODEC, complete).equals(complete), "Completion round trip"); + var cancel = new UploadPayloads.Cancel(id, "cancelled"); + check(roundTrip(UploadPayloads.Cancel.CODEC, cancel).equals(cancel), "Cancellation round trip"); + var status = new UploadPayloads.Status(id, false, true, "failed"); + check(roundTrip(UploadPayloads.Status.CODEC, status).equals(status), "Result round trip"); + FriendlyByteBuf oversized = new FriendlyByteBuf(Unpooled.buffer()); + try { + oversized.writeUUID(id).writeVarInt(0).writeByteArray(new byte[UploadBuffer.CHUNK_SIZE + 1]); + rejects(() -> UploadPayloads.Chunk.CODEC.decode(oversized), "Oversized chunk rejected at wire boundary"); + } finally { oversized.release(); } + } + + private static void http() throws Exception { + byte[] png = png(20, 10); + var limits = new ImageSafety.Limits(1024, List.of("png")); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + var handlers = Executors.newCachedThreadPool(Thread.ofPlatform().daemon().factory()); + var worker = Executors.newSingleThreadExecutor(Thread.ofPlatform().daemon().factory()); + server.setExecutor(handlers); + CountDownLatch stalled = new CountDownLatch(1), release = new CountDownLatch(1), stopped = new CountDownLatch(1); + server.createContext("/valid", exchange -> { + try (exchange) { + exchange.sendResponseHeaders(200, png.length); + exchange.getResponseBody().write(png); + } + }); + server.createContext("/oversized", exchange -> { + try (exchange) { + exchange.sendResponseHeaders(200, 2048); + exchange.getResponseBody().write(new byte[2048]); + } + }); + server.createContext("/chunked", exchange -> { + try (exchange) { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write(new byte[2048]); + } + }); + server.createContext("/error", exchange -> { try (exchange) { exchange.sendResponseHeaders(404, -1); } }); + server.createContext("/redirect", exchange -> { + try (exchange) { + exchange.getResponseHeaders().add("Location", "/valid"); + exchange.sendResponseHeaders(302, -1); + } + }); + server.createContext("/stall", exchange -> { + try (exchange) { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write(1); + exchange.getResponseBody().flush(); + stalled.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException exception) { Thread.currentThread().interrupt(); } + } + }); + server.start(); + ImageFetcher fetcher = new ImageFetcher(2); + String base = "http://127.0.0.1:" + server.getAddress().getPort(); + try { + try (var resources = new ImageFetcher.Resources()) { + check(Arrays.equals(png, fetcher.download(base + "/valid", limits, resources)), "Real HTTP image download"); + } + try (var resources = new ImageFetcher.Resources()) { + check(Arrays.equals(png, fetcher.download(base + "/redirect", limits, resources)), "HTTP redirect preserved"); + } + for (String route : List.of("/oversized", "/chunked", "/error")) { + try (var resources = new ImageFetcher.Resources()) { + rejects(() -> fetcher.download(base + route, limits, resources), "HTTP rejection " + route); + } + } + var resources = new ImageFetcher.Resources(); + resources.track(worker.submit(() -> { + try { fetcher.download(base + "/stall", limits, resources); } + catch (Exception expected) { } + finally { stopped.countDown(); } + })); + try { + check(stalled.await(5, TimeUnit.SECONDS), "Stalled body began"); + resources.close(); + check(stopped.await(5, TimeUnit.SECONDS), "Cancellation stops stalled HTTP body read"); + } finally { resources.close(); } + } finally { + release.countDown(); + fetcher.close(); + server.stop(0); + worker.shutdownNow(); + handlers.shutdownNow(); + } + } + + @FunctionalInterface private interface Throwing { void run() throws Exception; } + private static void rejects(Throwing action, String label) throws Exception { + boolean rejected = false; + try { action.run(); } catch (Exception expected) { rejected = true; } + check(rejected, label); + } + private static void check(boolean passed, String label) { + assertions++; + if (!passed) throw new AssertionError(label); + } +}