Added info uploader tool tip, moderators can delete shared cosmetics/opped users, added a black fill to preview boxes

main
MomokoKoigakubo 4 weeks ago
parent 063d85a398
commit 1d92791f74

@ -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.
*
* <p>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<String, Info> CACHE = new ConcurrentHashMap<>();
private static final java.util.Set<String> 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);
}
}

@ -285,7 +285,13 @@ public final class ClientSharedCosmeticCache {
/** /**
* Drop a cached shared cosmetic from this client releases its texture, forgets the * 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.
*
* <p>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) { public static boolean delete(String hash) {
if (!SharedCosmeticCache.isValidHash(hash)) return false; 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()); DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage());
} }
} }
IN_FLIGHT.remove(hash);
PENDING_REQUESTS.remove(hash);
try { try {
if (Files.deleteIfExists(fileFor(hash))) changed = true; if (Files.deleteIfExists(fileFor(hash))) changed = true;
} catch (IOException ex) { } catch (IOException ex) {

@ -1,6 +1,7 @@
package com.razz.dfashion.client.screen; package com.razz.dfashion.client.screen;
import com.razz.dfashion.block.ClosetBlockEntity; import com.razz.dfashion.block.ClosetBlockEntity;
import com.razz.dfashion.client.ClientCosmeticInfoCache;
import com.razz.dfashion.client.ClientSharedCosmeticCache; import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSharedCosmeticUploader; import com.razz.dfashion.client.ClientSharedCosmeticUploader;
import com.razz.dfashion.client.ClientSkinCache; 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.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.StringWidget; 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.gui.screens.Screen;
import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.entity.EntityRenderDispatcher; 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.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; 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_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_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_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_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 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<Button, String> sharedButtonHashes = new HashMap<>(); private final Map<Button, String> sharedButtonHashes = new HashMap<>();
private int lastKnownSharedCount = -1; 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<Button, String> infoButtonHashes = new HashMap<>();
private static final SimpleDateFormat INFO_DATE_FMT = new SimpleDateFormat("yyyy-MM-dd");
/** Filter bar Y (below the tab row). */ /** 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_Y = TAB_Y + TAB_H + 10;
private static final int SHARED_FILTER_H = 20; private static final int SHARED_FILTER_H = 20;
@ -291,6 +303,7 @@ public class ClosetScreen extends Screen {
for (Button btn : cosmeticButtons) removeWidget(btn); for (Button btn : cosmeticButtons) removeWidget(btn);
cosmeticButtons.clear(); cosmeticButtons.clear();
sharedButtonHashes.clear(); sharedButtonHashes.clear();
infoButtonHashes.clear();
if (selectedCategory == null) return; if (selectedCategory == null) return;
if (SKIN_TAB.equals(selectedCategory)) { if (SKIN_TAB.equals(selectedCategory)) {
@ -533,6 +546,22 @@ public class ClosetScreen extends Screen {
del.active = show; del.active = show;
cosmeticButtons.add(del); cosmeticButtons.add(del);
addRenderableWidget(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) { private void deleteShared(String hash) {
// Server drops the hash from this player's library; the local call releases the // Preemptively remove the hash from our local library attachment so the immediate
// baked model + texture and deletes the local blob cache file. // 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.sendToServer(new DeleteCosmetic(hash));
ClientSharedCosmeticCache.delete(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(); rebuildCosmeticRow();
} }
@ -868,16 +914,17 @@ public class ClosetScreen extends Screen {
String result = ClientSharedCosmeticUploader.upload(model, texture, name); String result = ClientSharedCosmeticUploader.upload(model, texture, name);
setShareStatus(result); setShareStatus(result);
// Clear the fields on successful submission so a second upload starts fresh. // Close the form on successful submission. "Uploading " means chunks went to the
// The uploader's status string is the only signal of outcome — "Uploading " means // server; "Already in library" means dedup short-circuited — both are terminal
// chunks went to the server, "Already in library" means dedup short-circuited // successes for this form, so drop back to the main closet and let the library
// (still a valid state, nothing to retry). Failures keep the fields populated so // sync drive the shared tab refresh. Failures keep the form open so the user can
// the user can correct and resubmit without re-entering paths. // correct the input without re-entering paths.
if (result != null if (result != null
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) { && (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
shareTextureField.setValue(""); shareTextureField.setValue("");
shareModelField.setValue(""); shareModelField.setValue("");
shareNameField.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 @Override
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) { public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
// Shared tab uses the full right-side region as a vertical scroll area. // 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)) { if (!isOverAnyWidget(mx, my)) {
String hit = findClickedEquippedCategory(player, mx, my); String hit = findClickedEquippedCategory(player, mx, my);
if (hit != null) { if (hit != null) {
selectCategory(hit);
com.razz.dfashion.cosmetic.CosmeticRef ref = com.razz.dfashion.cosmetic.CosmeticRef ref =
player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit); player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) { if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Shared s) {
scrollToCosmetic(hit, l.id()); // 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.setYHeadRot(displayYaw);
player.yBodyRotO = displayYaw; player.yBodyRotO = displayYaw;
player.yHeadRotO = 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<Button, String> 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 @Override
@ -1268,6 +1395,8 @@ public class ClosetScreen extends Screen {
&& entry.getKey().equals(l.id()); && entry.getKey().equals(l.id());
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR; 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). // Frame (green if currently equipped).
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor); graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor);
@ -1349,6 +1478,7 @@ public class ClosetScreen extends Screen {
String previewCategory = sharedCategoryForHash(hash); String previewCategory = sharedCategoryForHash(hash);
CosmeticRef liveRef = liveEquipped.get(previewCategory); CosmeticRef liveRef = liveEquipped.get(previewCategory);
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash()); 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, graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR); isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
@ -1404,6 +1534,7 @@ public class ClosetScreen extends Screen {
if (tex == null) continue; if (tex == null) continue;
boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash()); 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, graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR); isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR);

@ -105,4 +105,4 @@ public final class CosmeticCommands {
} }
private CosmeticCommands() {} private CosmeticCommands() {}
} }

@ -6,8 +6,10 @@ import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.CosmeticRef; import com.razz.dfashion.cosmetic.CosmeticRef;
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic; import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk; 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.DeleteCosmetic;
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic; 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 com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBuf;
@ -65,10 +67,18 @@ public final class CosmeticShareNetwork {
AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC, AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC,
CosmeticShareNetwork::onAssignShared CosmeticShareNetwork::onAssignShared
); );
registrar.playToServer(
RequestCosmeticInfo.TYPE, RequestCosmeticInfo.STREAM_CODEC,
CosmeticShareNetwork::onRequestCosmeticInfo
);
registrar.playToClient( registrar.playToClient(
CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC, CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC,
CosmeticShareNetwork::onCosmeticChunk 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. */ /** 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; boolean anyChanged = false;
List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size()); List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size());
for (CosmeticLibraryEntry entry : lib.entries()) { 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<String> extracted = extractBonesFromStoredBlob(server, entry.hash()); List<String> extracted = extractBonesFromStoredBlob(server, entry.hash());
if (extracted == null) { if (extracted == null) {
migrated.add(entry); // read/decode failed — leave untouched migrated.add(entry); // decode failed — leave untouched
continue; continue;
} }
if (extracted.equals(entry.bones())) { if (extracted.equals(entry.bones())) {
@ -185,6 +204,11 @@ public final class CosmeticShareNetwork {
); );
if (finalized == null) return; 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()); CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
if (!lib.contains(finalized.hash())) { if (!lib.contains(finalized.hash())) {
int n = lib.entries().size() + 1; int n = lib.entries().size() + 1;
@ -293,6 +317,18 @@ public final class CosmeticShareNetwork {
DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID()); DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID());
return; 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()); CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
if (!lib.contains(msg.hash())) return; if (!lib.contains(msg.hash())) return;
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash())); 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) { private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> ClientReceiver.accept(msg)); 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 * 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. * 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}.
*
* <p>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<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
Map<String, CosmeticRef> updated = null;
for (Map.Entry<String, CosmeticRef> 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. */ /** 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) { private static void copySlice(byte[] a, byte[] b, int off, byte[] out) {
int dst = 0; int dst = 0;

@ -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}.
*
* <p>Content is content-addressed, so byte-identical uploads from two players dedup on
* disk. The registry records the <b>first</b> 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<MinecraftServer, Map<String, UploadRecord>> 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<String, UploadRecord> getOrLoad(MinecraftServer server) {
Map<String, UploadRecord> cached = BY_SERVER.get(server);
if (cached != null) return cached;
Map<String, UploadRecord> loaded = loadFromDisk(server);
BY_SERVER.put(server, loaded);
return loaded;
}
private static Map<String, UploadRecord> loadFromDisk(MinecraftServer server) {
Map<String, UploadRecord> 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<String, JsonElement> 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<String, UploadRecord> 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<String, UploadRecord> 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<String, UploadRecord> 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<String, UploadRecord> map = getOrLoad(server);
synchronized (map) {
return map.get(hash);
}
}
/** Returns a snapshot of the full registry. Caller owns the returned map. */
public static Map<String, UploadRecord> snapshot(MinecraftServer server) {
Map<String, UploadRecord> 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<String, UploadRecord> map = getOrLoad(server);
boolean changed;
synchronized (map) {
changed = map.remove(hash) != null;
}
if (changed) saveToDisk(server, map);
}
}

@ -410,6 +410,15 @@ public final class SharedCosmeticCache {
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones); 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. */ /** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */
public static Map<String, Long> listCached(MinecraftServer server) { public static Map<String, Long> listCached(MinecraftServer server) {
Map<String, Long> out = new HashMap<>(); Map<String, Long> out = new HashMap<>();

@ -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.
*
* <p>{@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<CosmeticInfoResponse> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_info"));
public static final StreamCodec<FriendlyByteBuf, CosmeticInfoResponse> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.stringUtf8(64), CosmeticInfoResponse::hash,
ByteBufCodecs.stringUtf8(128), CosmeticInfoResponse::uploaderName,
ByteBufCodecs.VAR_LONG, CosmeticInfoResponse::uploadedAt,
CosmeticInfoResponse::new
);
@Override
public Type<CosmeticInfoResponse> type() {
return TYPE;
}
}

@ -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<RequestCosmeticInfo> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic_info"));
public static final StreamCodec<FriendlyByteBuf, RequestCosmeticInfo> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.stringUtf8(64), RequestCosmeticInfo::hash,
RequestCosmeticInfo::new
);
@Override
public Type<RequestCosmeticInfo> type() {
return TYPE;
}
}
Loading…
Cancel
Save