You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

201 lines
8.4 KiB
Java

package com.razz.dfashion.client;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.bbmodel.BbModelParser;
import com.razz.dfashion.bbmodel.Bbmodel;
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
import com.razz.dfashion.skin.SafePngReader;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.NoSuchAlgorithmException;
import java.util.zip.Deflater;
/**
* Author-side upload pipeline for shared cosmetics. Runs only on the uploading client.
*
* <p>Security-critical: this is the <b>only</b> place in the entire system where
* {@link BbModelParser} and {@link SafePngReader} see untrusted bytes. Everything
* downstream (server, receivers) operates on the validated binary produced here and on
* already-deflated RGBA. The output of this file is what every other client will
* eventually decode — if this file doesn't reject something, nothing else will either.
*
* <p>Anti-DoS: before sending, the server-authoritative {@link
* ClientSharedCosmeticCache#libraryContains} check short-circuits uploads that the
* server already has in this player's library. Re-uploads of identical content are
* therefore zero-bandwidth after the first success.
*/
public final class ClientSharedCosmeticUploader {
private ClientSharedCosmeticUploader() {}
public static String upload(Path bbmodelFile, Path textureFile, String displayName) {
return upload(bbmodelFile, textureFile, displayName, 0, 1);
}
public static String upload(Path bbmodelFile, Path textureFile, String displayName,
int frametime, int frames) {
String result = doUpload(bbmodelFile, textureFile, displayName, frametime, frames);
DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result);
return result;
}
private static String doUpload(Path bbmodelFile, Path textureFile, String displayName,
int frametime, int frames) {
// Validate the flipbook spec early so we don't waste a parse on an obviously bad input.
boolean staticFb = (frametime == 0 && frames == 1);
boolean animatedFb = (frametime > 0
&& frametime <= com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMETIME
&& frames > 1
&& frames <= com.razz.dfashion.cosmetic.share.SharedCosmeticCache.MAX_FLIPBOOK_FRAMES);
if (!staticFb && !animatedFb) {
return "Bad flipbook: frametime=" + frametime + " frames=" + frames;
}
if (!Files.isRegularFile(bbmodelFile)) return "Not a file: " + bbmodelFile;
if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile;
long bbSize;
long texSize;
try {
bbSize = Files.size(bbmodelFile);
texSize = Files.size(textureFile);
} catch (IOException ex) {
return "Size check failed: " + ex.getMessage();
}
if (bbSize > 16L * 1024 * 1024) return "bbmodel too large: " + bbSize;
if (texSize > SafeImageLoader.MAX_PNG_FILE_BYTES) return "PNG too large: " + texSize;
// 1. Parse bbmodel via BbModelParser (validates everything).
Bbmodel bbmodel;
try (Reader reader = Files.newBufferedReader(bbmodelFile)) {
bbmodel = BbModelParser.parse(reader);
} catch (Exception ex) {
return "bbmodel rejected: " + ex.getMessage();
}
// 2. Decode PNG via SafePngReader (memory-safe, RGBA only).
SafePngReader.Image decoded;
try {
byte[] pngBytes = Files.readAllBytes(textureFile);
decoded = SafePngReader.decode(pngBytes);
} catch (IOException ex) {
return "PNG rejected: " + ex.getMessage();
}
// 3. Encode bbmodel canonically via BbmodelCodec. This is the form that will cross
// the wire, be stored, hashed, and eventually decoded by every receiver.
byte[] canonicalBbmodelBin;
ByteBuf out = Unpooled.buffer(1024);
try {
BbmodelCodec.CODEC.encode(out, bbmodel);
if (out.readableBytes() > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) {
return "canonical bbmodel too large: " + out.readableBytes();
}
canonicalBbmodelBin = new byte[out.readableBytes()];
out.readBytes(canonicalBbmodelBin);
} finally {
out.release();
}
// 4. For flipbooks, the PNG height must divide cleanly by frame count. Reject early
// so the server doesn't have to.
if (frames > 1 && decoded.height % frames != 0) {
return "Flipbook frames=" + frames + " doesn't divide texture height " + decoded.height;
}
// 5. Compute content hash — must match what the server will compute.
String hash;
try {
hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin,
decoded.width, decoded.height,
frametime, frames,
decoded.rgba);
} catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable";
}
// 5. Anti-DoS: if the server already has this hash in our library, skip the wire.
if (ClientSharedCosmeticCache.libraryContains(hash)) {
return "Already in library (" + hash.substring(0, 8) + "…); skipped";
}
// 6. Deflate raw RGBA.
byte[] deflated = deflate(decoded.rgba);
if (deflated.length > SharedCosmeticCache.MAX_DEFLATED_BYTES) {
return "RGBA deflated too large: " + deflated.length;
}
ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return "Not connected";
// 7. Assemble wire payload = bbmodel binary || deflated RGBA. Chunk and send.
int bbmodelLen = canonicalBbmodelBin.length;
int payloadLen = bbmodelLen + deflated.length;
int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize);
for (int i = 0; i < total; i++) {
int off = i * chunkSize;
int len = Math.min(chunkSize, payloadLen - off);
byte[] slice = sliceConcat(canonicalBbmodelBin, deflated, off, len);
conn.send(new ServerboundCustomPayloadPacket(
new UploadCosmeticChunk(
i, total, bbmodelLen,
decoded.width, decoded.height,
frametime, frames,
slice
)));
}
return "Uploading " + decoded.width + "x" + decoded.height + " ("
+ payloadLen + " bytes, " + total + " chunks, hash "
+ hash.substring(0, 8) + "…) [" + displayName + "]";
}
/** Slice bytes from {@code a ‖ b} into a new array of length {@code len} starting at {@code off}. */
private static byte[] sliceConcat(byte[] a, byte[] b, int off, int len) {
byte[] out = new byte[len];
int dst = 0;
if (off < a.length) {
int take = Math.min(len, a.length - off);
System.arraycopy(a, off, out, dst, take);
dst += take;
len -= take;
off += take;
}
if (len > 0) {
int bOff = off - a.length;
System.arraycopy(b, bOff, out, dst, len);
}
return out;
}
private static byte[] deflate(byte[] raw) {
Deflater def = new Deflater(Deflater.BEST_COMPRESSION);
try {
def.setInput(raw);
def.finish();
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64);
byte[] buf = new byte[64 * 1024];
while (!def.finished()) {
int n = def.deflate(buf);
if (n == 0) break;
out.write(buf, 0, n);
}
return out.toByteArray();
} finally {
def.end();
}
}
}