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