Added info uploader tool tip, moderators can delete shared cosmetics/opped users, added a black fill to preview boxes
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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in New Issue