From 1d92791f74110568d671df85213e446e09f7df69 Mon Sep 17 00:00:00 2001 From: MomokoKoigakubo Date: Tue, 21 Apr 2026 08:00:44 -0400 Subject: [PATCH] Added info uploader tool tip, moderators can delete shared cosmetics/opped users, added a black fill to preview boxes --- .../client/ClientCosmeticInfoCache.java | 58 +++++++ .../client/ClientSharedCosmeticCache.java | 10 +- .../dfashion/client/screen/ClosetScreen.java | 151 ++++++++++++++++-- .../dfashion/cosmetic/CosmeticCommands.java | 2 +- .../cosmetic/share/CosmeticShareNetwork.java | 126 ++++++++++++++- .../share/CosmeticUploadRegistry.java | 147 +++++++++++++++++ .../cosmetic/share/SharedCosmeticCache.java | 9 ++ .../share/packet/CosmeticInfoResponse.java | 35 ++++ .../share/packet/RequestCosmeticInfo.java | 31 ++++ 9 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/razz/dfashion/client/ClientCosmeticInfoCache.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/share/CosmeticUploadRegistry.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticInfoResponse.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmeticInfo.java diff --git a/src/main/java/com/razz/dfashion/client/ClientCosmeticInfoCache.java b/src/main/java/com/razz/dfashion/client/ClientCosmeticInfoCache.java new file mode 100644 index 0000000..392859b --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClientCosmeticInfoCache.java @@ -0,0 +1,58 @@ +package com.razz.dfashion.client; + +import com.razz.dfashion.cosmetic.share.packet.RequestCosmeticInfo; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Client-side cache of uploader metadata for shared cosmetics. Fetched lazily from the + * server via {@link RequestCosmeticInfo} the first time the UI asks about a hash; the + * server's {@link com.razz.dfashion.cosmetic.share.packet.CosmeticInfoResponse} lands in + * {@link #onResponse} and the cached value is then read synchronously by the tooltip. + * + *

Entries are process-lifetime; a new server world/login starts empty. This is fine + * for a UI hint — nothing functional depends on freshness and OP-initiated deletes on + * the server can't produce a mismatch the user would notice. + */ +public final class ClientCosmeticInfoCache { + + public record Info(String uploaderName, long uploadedAt) { + public boolean isKnown() { + return !uploaderName.isEmpty() || uploadedAt != 0L; + } + } + + private static final Map CACHE = new ConcurrentHashMap<>(); + private static final java.util.Set PENDING = ConcurrentHashMap.newKeySet(); + + private ClientCosmeticInfoCache() {} + + /** Known info for the hash, or {@code null} if not yet received. */ + public static Info get(String hash) { + return CACHE.get(hash); + } + + /** + * Trigger a fetch if we've never requested this hash. Idempotent — repeat calls while + * a request is pending are coalesced. Once a response lands the cache stays populated + * for the rest of the session. + */ + public static void requestIfMissing(String hash) { + if (hash == null || hash.isEmpty()) return; + if (CACHE.containsKey(hash)) return; + if (!PENDING.add(hash)) return; + ClientSharedCosmeticCache.sendToServer(new RequestCosmeticInfo(hash)); + } + + public static void onResponse(String hash, String uploaderName, long uploadedAt) { + CACHE.put(hash, new Info(uploaderName == null ? "" : uploaderName, uploadedAt)); + PENDING.remove(hash); + } + + /** Drop a hash's info (e.g. after an OP-delete) so the UI won't show stale data. */ + public static void forget(String hash) { + CACHE.remove(hash); + PENDING.remove(hash); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java index 165fa46..61c8694 100644 --- a/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java +++ b/src/main/java/com/razz/dfashion/client/ClientSharedCosmeticCache.java @@ -285,7 +285,13 @@ public final class ClientSharedCosmeticCache { /** * Drop a cached shared cosmetic from this client — releases its texture, forgets the - * baked entry, removes the local cache file. The server's copy is untouched. + * baked entry, removes the local cache file, and clears any in-flight / pending + * request state. The server's copy is untouched. + * + *

Clearing {@code IN_FLIGHT} + {@code PENDING_REQUESTS} matters for the delete → + * re-upload round trip: any request sent before the server's delete landed gets no + * response, so without this cleanup the short-circuit in {@link #requestIfMissing} + * would prevent re-downloading the blob after a re-upload of identical content. */ public static boolean delete(String hash) { if (!SharedCosmeticCache.isValidHash(hash)) return false; @@ -298,6 +304,8 @@ public final class ClientSharedCosmeticCache { DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage()); } } + IN_FLIGHT.remove(hash); + PENDING_REQUESTS.remove(hash); try { if (Files.deleteIfExists(fileFor(hash))) changed = true; } catch (IOException ex) { diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java index 85898ae..9d23f9e 100644 --- a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java +++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java @@ -1,6 +1,7 @@ package com.razz.dfashion.client.screen; import com.razz.dfashion.block.ClosetBlockEntity; +import com.razz.dfashion.client.ClientCosmeticInfoCache; import com.razz.dfashion.client.ClientSharedCosmeticCache; import com.razz.dfashion.client.ClientSharedCosmeticUploader; import com.razz.dfashion.client.ClientSkinCache; @@ -35,6 +36,7 @@ import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.renderer.entity.EntityRenderDispatcher; @@ -63,7 +65,9 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -109,6 +113,7 @@ public class ClosetScreen extends Screen { private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library + private static final int COSMETIC_BACKDROP_COLOR = 0xFF000000; // opaque black fill behind the 3D mini-player so the world doesn't poke through private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility @@ -173,6 +178,13 @@ public class ClosetScreen extends Screen { private final Map sharedButtonHashes = new HashMap<>(); private int lastKnownSharedCount = -1; + /** Info-button hash map so the tick handler can refresh tooltips as upload metadata + * arrives from the server. Cleared in {@link #rebuildCosmeticRow} alongside the + * other per-row widget state. */ + private final Map infoButtonHashes = new HashMap<>(); + + private static final SimpleDateFormat INFO_DATE_FMT = new SimpleDateFormat("yyyy-MM-dd"); + /** Filter bar Y (below the tab row). */ private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10; private static final int SHARED_FILTER_H = 20; @@ -291,6 +303,7 @@ public class ClosetScreen extends Screen { for (Button btn : cosmeticButtons) removeWidget(btn); cosmeticButtons.clear(); sharedButtonHashes.clear(); + infoButtonHashes.clear(); if (selectedCategory == null) return; if (SKIN_TAB.equals(selectedCategory)) { @@ -533,6 +546,22 @@ public class ClosetScreen extends Screen { del.active = show; cosmeticButtons.add(del); addRenderableWidget(del); + + // Small "?" affordance top-left — click to force a fresh request, hover to read + // the cached uploader info. Tooltip content is refreshed per-tick from + // ClientCosmeticInfoCache as responses land. + Button info = Button.builder( + Component.literal("?"), + b -> ClientCosmeticInfoCache.requestIfMissing(hash) + ).bounds(boxX, boxY - 12, 12, 12) + .tooltip(Tooltip.create(Component.literal("Loading…"))) + .build(); + info.visible = show; + info.active = show; + cosmeticButtons.add(info); + addRenderableWidget(info); + infoButtonHashes.put(info, hash); + ClientCosmeticInfoCache.requestIfMissing(hash); } } @@ -605,10 +634,27 @@ public class ClosetScreen extends Screen { } private void deleteShared(String hash) { - // Server drops the hash from this player's library; the local call releases the - // baked model + texture and deletes the local blob cache file. + // Preemptively remove the hash from our local library attachment so the immediate + // rebuildCosmeticRow below doesn't re-create a button for the dying entry and fire + // a speculative RequestCosmetic the server can no longer fulfill (which would leave + // IN_FLIGHT/PENDING_REQUESTS permanently stuck and break re-uploads of identical + // content). The next server sync is authoritative and will overwrite this anyway. + LocalPlayer player = Minecraft.getInstance().player; + if (player != null) { + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (lib.contains(hash)) { + player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(hash)); + } + } + // Server drops the hash from this player's library (or globally, if OP); the local + // call releases the baked model + texture, deletes the local blob cache file, and + // clears any in-flight / pending-request state so a re-upload of identical content + // triggers a fresh download. ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash)); ClientSharedCosmeticCache.delete(hash); + // Drop the uploader-info cache too so a re-upload re-fetches the (possibly new) + // uploader metadata instead of showing the stale "unknown" result from before. + ClientCosmeticInfoCache.forget(hash); rebuildCosmeticRow(); } @@ -868,16 +914,17 @@ public class ClosetScreen extends Screen { String result = ClientSharedCosmeticUploader.upload(model, texture, name); setShareStatus(result); - // Clear the fields on successful submission so a second upload starts fresh. - // The uploader's status string is the only signal of outcome — "Uploading " means - // chunks went to the server, "Already in library" means dedup short-circuited - // (still a valid state, nothing to retry). Failures keep the fields populated so - // the user can correct and resubmit without re-entering paths. + // Close the form on successful submission. "Uploading " means chunks went to the + // server; "Already in library" means dedup short-circuited — both are terminal + // successes for this form, so drop back to the main closet and let the library + // sync drive the shared tab refresh. Failures keep the form open so the user can + // correct the input without re-entering paths. if (result != null && (result.startsWith("Uploading ") || result.startsWith("Already in library"))) { shareTextureField.setValue(""); shareModelField.setValue(""); shareNameField.setValue(""); + closeShareForm(); } } @@ -966,6 +1013,49 @@ public class ClosetScreen extends Screen { } } + /** + * Scroll the shared-tab grid so a specific hash is visible. If the current bone filter + * hides the entry, the filter is cleared to "All" first — otherwise we'd appear to + * navigate to an empty grid. + */ + private void scrollToSharedCosmetic(String hash) { + LocalPlayer player = Minecraft.getInstance().player; + if (player == null) return; + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + + int stride = COSMETIC_SIZE + COSMETIC_SPACING; + int gridRight = this.width - 10; + int boxesPerRow = Math.max(1, (gridRight - COSMETIC_ROW_LEFT + COSMETIC_SPACING) / stride); + + int targetRow = findSharedRow(lib, hash, boxesPerRow, sharedBoneFilter); + if (targetRow < 0 && sharedBoneFilter != null) { + // Hidden by current filter — drop it and retry with "All". + sharedBoneFilter = null; + targetRow = findSharedRow(lib, hash, boxesPerRow, null); + } + if (targetRow < 0) return; + sharedVerticalScroll = targetRow * stride; + clampSharedScroll(); + rebuildCosmeticRow(); + } + + /** Row index (0-based) of {@code hash} in the shared grid under the given filter, or -1 if absent. */ + private int findSharedRow(CosmeticLibrary lib, String hash, int boxesPerRow, @Nullable String filter) { + int idx = 0; + for (CosmeticLibraryEntry entry : lib.entries()) { + if (filter != null) { + String saved = sharedBoneFilter; + sharedBoneFilter = filter; + boolean ok = entryMatchesSharedFilter(entry); + sharedBoneFilter = saved; + if (!ok) continue; + } + if (entry.hash().equals(hash)) return idx / boxesPerRow; + idx++; + } + return -1; + } + @Override public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) { // Shared tab uses the full right-side region as a vertical scroll area. @@ -1043,11 +1133,18 @@ public class ClosetScreen extends Screen { if (!isOverAnyWidget(mx, my)) { String hit = findClickedEquippedCategory(player, mx, my); if (hit != null) { - selectCategory(hit); com.razz.dfashion.cosmetic.CosmeticRef ref = player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit); - if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) { - scrollToCosmetic(hit, l.id()); + if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Shared s) { + // Shared cosmetics don't live on the bone-category tab even + // though they appear there — their home is the shared tab. + selectCategory(SHARED_TAB); + scrollToSharedCosmetic(s.hash()); + } else { + selectCategory(hit); + if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) { + scrollToCosmetic(hit, l.id()); + } } } } @@ -1198,6 +1295,36 @@ public class ClosetScreen extends Screen { player.setYHeadRot(displayYaw); player.yBodyRotO = displayYaw; player.yHeadRotO = displayYaw; + + refreshInfoTooltips(); + } + + /** + * Rewrite each info-button's tooltip from the current {@link ClientCosmeticInfoCache} + * snapshot. Responses arrive asynchronously, so a tooltip might start as "Loading…" + * and flip to real uploader text a tick or two later. We overwrite unconditionally — + * Tooltip construction is cheap relative to per-hover render work. + */ + private void refreshInfoTooltips() { + if (infoButtonHashes.isEmpty()) return; + for (Map.Entry e : infoButtonHashes.entrySet()) { + Button button = e.getKey(); + String hash = e.getValue(); + ClientCosmeticInfoCache.Info info = ClientCosmeticInfoCache.get(hash); + Component tip; + if (info == null) { + tip = Component.literal("Loading…"); + } else if (!info.isKnown()) { + tip = Component.literal("Uploader: unknown"); + } else { + String who = info.uploaderName().isEmpty() ? "unknown" : info.uploaderName(); + String when = info.uploadedAt() > 0 + ? INFO_DATE_FMT.format(new Date(info.uploadedAt())) + : "unknown date"; + tip = Component.literal("Uploader: " + who + "\nUploaded: " + when); + } + button.setTooltip(Tooltip.create(tip)); + } } @Override @@ -1268,6 +1395,8 @@ public class ClosetScreen extends Screen { && entry.getKey().equals(l.id()); int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR; + // Opaque backdrop so the 3D world behind the closet doesn't poke through the preview. + graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR); // Frame (green if currently equipped). graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor); @@ -1349,6 +1478,7 @@ public class ClosetScreen extends Screen { String previewCategory = sharedCategoryForHash(hash); CosmeticRef liveRef = liveEquipped.get(previewCategory); boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash()); + graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR); graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR); @@ -1404,6 +1534,7 @@ public class ClosetScreen extends Screen { if (tex == null) continue; boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash()); + graphics.fill(fx0, fy0, fx1, fy1, COSMETIC_BACKDROP_COLOR); graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR); diff --git a/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java b/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java index 458ebaa..b59a3ad 100644 --- a/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java +++ b/src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java @@ -105,4 +105,4 @@ public final class CosmeticCommands { } private CosmeticCommands() {} -} +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java index 7eb9278..51004b0 100644 --- a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java +++ b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticShareNetwork.java @@ -6,8 +6,10 @@ import com.razz.dfashion.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticRef; import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic; import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk; +import com.razz.dfashion.cosmetic.share.packet.CosmeticInfoResponse; import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic; import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic; +import com.razz.dfashion.cosmetic.share.packet.RequestCosmeticInfo; import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk; import io.netty.buffer.ByteBuf; @@ -65,10 +67,18 @@ public final class CosmeticShareNetwork { AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC, CosmeticShareNetwork::onAssignShared ); + registrar.playToServer( + RequestCosmeticInfo.TYPE, RequestCosmeticInfo.STREAM_CODEC, + CosmeticShareNetwork::onRequestCosmeticInfo + ); registrar.playToClient( CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC, CosmeticShareNetwork::onCosmeticChunk ); + registrar.playToClient( + CosmeticInfoResponse.TYPE, CosmeticInfoResponse.STREAM_CODEC, + CosmeticShareNetwork::onCosmeticInfoResponse + ); } /** Clear any in-flight upload state when a player disconnects so buffers don't leak. */ @@ -98,9 +108,18 @@ public final class CosmeticShareNetwork { boolean anyChanged = false; List migrated = new ArrayList<>(lib.entries().size()); for (CosmeticLibraryEntry entry : lib.entries()) { + // If the blob is gone (e.g. OP admin deletion) drop the library entry so it + // doesn't linger as a broken ref. This is the offline-player branch of the + // admin-delete cleanup — online players are pruned directly at delete time. + if (!SharedCosmeticCache.has(server, entry.hash())) { + anyChanged = true; + DecoFashion.LOGGER.info("Cosmetic library pruned (blob missing): player={} hash={}", + player.getUUID(), entry.hash()); + continue; + } List extracted = extractBonesFromStoredBlob(server, entry.hash()); if (extracted == null) { - migrated.add(entry); // read/decode failed — leave untouched + migrated.add(entry); // decode failed — leave untouched continue; } if (extracted.equals(entry.bones())) { @@ -185,6 +204,11 @@ public final class CosmeticShareNetwork { ); if (finalized == null) return; + // Remember who first uploaded content for this hash. Content-addressed dedup + // means duplicate uploads land on the same blob — first-uploader wins. + CosmeticUploadRegistry.recordIfAbsent( + server, finalized.hash(), player.getUUID(), player.getGameProfile().name()); + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); if (!lib.contains(finalized.hash())) { int n = lib.entries().size() + 1; @@ -293,6 +317,18 @@ public final class CosmeticShareNetwork { DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID()); return; } + + // OPs (gamemaster+) always delete globally — file, uploader record, and every + // online player's library/equipped state. Offline players self-heal on login. + // Non-OPs only affect their own library. + MinecraftServer server = player.level().getServer(); + boolean isOp = server != null + && player.permissions().hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER); + if (isOp) { + adminDelete(server, msg.hash()); + return; + } + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); if (!lib.contains(msg.hash())) return; player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash())); @@ -317,10 +353,41 @@ public final class CosmeticShareNetwork { }); } + private static void onRequestCosmeticInfo(RequestCosmeticInfo msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> { + if (!(ctx.player() instanceof ServerPlayer player)) return; + MinecraftServer server = player.level().getServer(); + if (server == null) return; + if (!SharedCosmeticCache.isValidHash(msg.hash())) { + DecoFashion.LOGGER.warn("CosmeticInfo request from {}: invalid hash rejected", player.getUUID()); + return; + } + + // Non-OPs can only look up hashes in their own library — otherwise anyone could + // enumerate every blob on the server. + boolean isOp = player.permissions() + .hasPermission(net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER); + if (!isOp) { + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (!lib.contains(msg.hash())) return; + } + + CosmeticUploadRegistry.UploadRecord rec = CosmeticUploadRegistry.get(server, msg.hash()); + String name = rec == null ? "" : rec.uploaderName(); + long uploadedAt = rec == null ? 0L : rec.uploadedAt(); + player.connection.send(new ClientboundCustomPayloadPacket( + new CosmeticInfoResponse(msg.hash(), name, uploadedAt))); + }); + } + private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) { ctx.enqueueWork(() -> ClientReceiver.accept(msg)); } + private static void onCosmeticInfoResponse(CosmeticInfoResponse msg, IPayloadContext ctx) { + ctx.enqueueWork(() -> InfoReceiver.accept(msg)); + } + /** * Client-only trampoline. Kept separate so the server-side handler routing can be * dist-gated without pulling in any client-only classes on dedicated servers. @@ -338,6 +405,63 @@ public final class CosmeticShareNetwork { } } + /** Client-only trampoline for {@link CosmeticInfoResponse}. Same dist-gating rationale as {@link ClientReceiver}. */ + private static final class InfoReceiver { + static void accept(CosmeticInfoResponse msg) { + if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return; + com.razz.dfashion.client.ClientCosmeticInfoCache.onResponse( + msg.hash(), msg.uploaderName(), msg.uploadedAt()); + } + } + + /** + * Admin-level delete: removes the {@code .dfcos} blob, forgets the uploader record, + * and prunes every online player's library + equipped slots that referenced the hash. + * Offline players pick up the cleanup on their next login via {@link #onPlayerLoggedIn}. + * + *

Caller is responsible for the permission check — this method itself is unguarded + * so it can be invoked from either command or packet paths. + * + * @return {@code true} if a blob file was deleted; {@code false} if the hash was unknown. + */ + public static boolean adminDelete(MinecraftServer server, String hash) { + if (!SharedCosmeticCache.isValidHash(hash)) return false; + boolean deleted; + try { + deleted = SharedCosmeticCache.deleteBlob(server, hash); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Admin cosmetic delete: file delete failed for {}", hash, ex); + return false; + } + + CosmeticUploadRegistry.forget(server, hash); + + int prunedPlayers = 0; + int unequipped = 0; + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()); + if (lib.contains(hash)) { + player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(hash)); + prunedPlayers++; + } + Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); + Map updated = null; + for (Map.Entry e : equipped.entrySet()) { + if (e.getValue() instanceof CosmeticRef.Shared s && hash.equals(s.hash())) { + if (updated == null) updated = new HashMap<>(equipped); + updated.remove(e.getKey()); + } + } + if (updated != null) { + player.setData(CosmeticAttachments.EQUIPPED.get(), updated); + unequipped++; + } + } + DecoFashion.LOGGER.info("Admin cosmetic delete: hash={} fileDeleted={} prunedLibraries={} unequippedPlayers={}", + hash, deleted, prunedPlayers, unequipped); + return deleted; + } + /** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */ private static void copySlice(byte[] a, byte[] b, int off, byte[] out) { int dst = 0; diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticUploadRegistry.java b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticUploadRegistry.java new file mode 100644 index 0000000..22e3a47 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/CosmeticUploadRegistry.java @@ -0,0 +1,147 @@ +package com.razz.dfashion.cosmetic.share; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.razz.dfashion.DecoFashion; + +import net.minecraft.server.MinecraftServer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.WeakHashMap; + +/** + * Server-side index mapping a shared-cosmetic hash to the player who first uploaded it. + * Persisted as JSON alongside the {@code .dfcos} blobs in {@link SharedCosmeticCache}. + * + *

Content is content-addressed, so byte-identical uploads from two players dedup on + * disk. The registry records the first uploader and never overwrites — otherwise + * an attacker could launder authorship by re-uploading a popular cosmetic. + */ +public final class CosmeticUploadRegistry { + + public record UploadRecord(UUID uploader, String uploaderName, long uploadedAt) {} + + private static final String FILE_NAME = "uploaders.json"; + + /** One in-memory cache per live {@link MinecraftServer}. WeakHashMap so server shutdown + * drops the entry automatically. Per-map writes are synchronized on the map. */ + private static final Map> BY_SERVER = + Collections.synchronizedMap(new WeakHashMap<>()); + + private CosmeticUploadRegistry() {} + + private static Path fileFor(MinecraftServer server) { + return server.getServerDirectory().resolve("decofashion/cosmetics").resolve(FILE_NAME); + } + + private static synchronized Map getOrLoad(MinecraftServer server) { + Map cached = BY_SERVER.get(server); + if (cached != null) return cached; + Map loaded = loadFromDisk(server); + BY_SERVER.put(server, loaded); + return loaded; + } + + private static Map loadFromDisk(MinecraftServer server) { + Map out = new HashMap<>(); + Path path = fileFor(server); + if (!Files.isRegularFile(path)) return out; + try { + String json = Files.readString(path, StandardCharsets.UTF_8); + JsonElement root = JsonParser.parseString(json); + if (!root.isJsonObject()) return out; + for (Map.Entry e : root.getAsJsonObject().entrySet()) { + String hash = e.getKey(); + if (!SharedCosmeticCache.isValidHash(hash)) continue; + if (!e.getValue().isJsonObject()) continue; + JsonObject row = e.getValue().getAsJsonObject(); + String uuidStr = row.has("uuid") ? row.get("uuid").getAsString() : null; + String name = row.has("name") ? row.get("name").getAsString() : ""; + long ts = row.has("uploadedAt") ? row.get("uploadedAt").getAsLong() : 0L; + if (uuidStr == null) continue; + try { + out.put(hash, new UploadRecord(UUID.fromString(uuidStr), name, ts)); + } catch (IllegalArgumentException bad) { + // skip malformed entry + } + } + } catch (IOException | RuntimeException ex) { + DecoFashion.LOGGER.warn("CosmeticUploadRegistry: load failed ({}); starting empty", ex.getMessage()); + } + return out; + } + + private static void saveToDisk(MinecraftServer server, Map data) { + Path path = fileFor(server); + try { + Files.createDirectories(path.getParent()); + JsonObject root = new JsonObject(); + // Snapshot under the map's monitor so we don't serialize during a mutation. + synchronized (data) { + for (Map.Entry e : data.entrySet()) { + JsonObject row = new JsonObject(); + row.addProperty("uuid", e.getValue().uploader().toString()); + row.addProperty("name", e.getValue().uploaderName()); + row.addProperty("uploadedAt", e.getValue().uploadedAt()); + root.add(e.getKey(), row); + } + } + Path tmp = path.resolveSibling(FILE_NAME + ".tmp"); + Files.writeString(tmp, root.toString(), StandardCharsets.UTF_8); + Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ex) { + DecoFashion.LOGGER.warn("CosmeticUploadRegistry: save failed ({})", ex.getMessage()); + } + } + + /** Record the first uploader for a hash. Subsequent calls for the same hash are no-ops. */ + public static void recordIfAbsent(MinecraftServer server, String hash, UUID uploader, String uploaderName) { + if (!SharedCosmeticCache.isValidHash(hash) || uploader == null) return; + Map map = getOrLoad(server); + boolean changed; + synchronized (map) { + changed = !map.containsKey(hash); + if (changed) { + map.put(hash, new UploadRecord(uploader, uploaderName == null ? "" : uploaderName, + System.currentTimeMillis())); + } + } + if (changed) saveToDisk(server, map); + } + + public static UploadRecord get(MinecraftServer server, String hash) { + if (!SharedCosmeticCache.isValidHash(hash)) return null; + Map map = getOrLoad(server); + synchronized (map) { + return map.get(hash); + } + } + + /** Returns a snapshot of the full registry. Caller owns the returned map. */ + public static Map snapshot(MinecraftServer server) { + Map map = getOrLoad(server); + synchronized (map) { + return new HashMap<>(map); + } + } + + /** Drop any record for a hash (called when the blob is deleted). No-op if absent. */ + public static void forget(MinecraftServer server, String hash) { + if (!SharedCosmeticCache.isValidHash(hash)) return; + Map map = getOrLoad(server); + boolean changed; + synchronized (map) { + changed = map.remove(hash) != null; + } + if (changed) saveToDisk(server, map); + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java b/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java index 0b8bce4..77a848e 100644 --- a/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java +++ b/src/main/java/com/razz/dfashion/cosmetic/share/SharedCosmeticCache.java @@ -410,6 +410,15 @@ public final class SharedCosmeticCache { return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones); } + /** + * Remove a blob from disk. Returns {@code true} if a file was deleted. Idempotent — + * calling on an unknown or already-removed hash is safe and returns {@code false}. + */ + public static boolean deleteBlob(MinecraftServer server, String hash) throws IOException { + if (!isValidHash(hash)) throw new IOException("invalid hash"); + return Files.deleteIfExists(fileFor(server, hash)); + } + /** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */ public static Map listCached(MinecraftServer server) { Map out = new HashMap<>(); diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticInfoResponse.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticInfoResponse.java new file mode 100644 index 0000000..b17c048 --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/CosmeticInfoResponse.java @@ -0,0 +1,35 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Server → client. Uploader metadata response for a specific cosmetic hash. + * + *

{@code uploaderName} is empty if the uploader is unknown (legacy uploads made before + * the registry existed, or blobs cleared from the registry). {@code uploadedAt} is epoch + * millis; {@code 0} means "unknown". + */ +public record CosmeticInfoResponse(String hash, String uploaderName, long uploadedAt) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_info")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), CosmeticInfoResponse::hash, + ByteBufCodecs.stringUtf8(128), CosmeticInfoResponse::uploaderName, + ByteBufCodecs.VAR_LONG, CosmeticInfoResponse::uploadedAt, + CosmeticInfoResponse::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file diff --git a/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmeticInfo.java b/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmeticInfo.java new file mode 100644 index 0000000..1130c1f --- /dev/null +++ b/src/main/java/com/razz/dfashion/cosmetic/share/packet/RequestCosmeticInfo.java @@ -0,0 +1,31 @@ +package com.razz.dfashion.cosmetic.share.packet; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +/** + * Client → server. Request uploader metadata for {@code hash}. Server responds with a + * {@link CosmeticInfoResponse} iff the hash is valid and either (a) it's in the caller's + * personal library or (b) the caller is an OP — to block arbitrary blob enumeration. + */ +public record RequestCosmeticInfo(String hash) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic_info")); + + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + ByteBufCodecs.stringUtf8(64), RequestCosmeticInfo::hash, + RequestCosmeticInfo::new + ); + + @Override + public Type type() { + return TYPE; + } +} \ No newline at end of file