Skip to content

Commit

Permalink
Add support for directory mcpack
Browse files Browse the repository at this point in the history
  • Loading branch information
wode490390 committed Feb 17, 2024
1 parent ce72618 commit c4df1d6
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 21 deletions.
1 change: 1 addition & 0 deletions src/main/java/cn/nukkit/SharedConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public final class SharedConstants {

public static final boolean USE_FUNCTION_EVENT_EXECUTOR = true;

public static final boolean RESOURCE_PACK_ENCRYPTION = false;
public static final int RESOURCE_PACK_CHUNK_SIZE = 128 * 1024; // 128KB

public static final boolean ENABLE_BLOCK_DESTROY_SPEED_COMPATIBILITY = true;
Expand Down
51 changes: 50 additions & 1 deletion src/main/java/cn/nukkit/resourcepacks/ResourcePackManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@
import com.google.common.io.Files;
import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.filefilter.TrueFileFilter;

import javax.annotation.Nullable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.attribute.FileTime;
import java.util.Collection;
import java.util.Map;
import java.util.TreeSet;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Log4j2
public class ResourcePackManager {
Expand Down Expand Up @@ -42,7 +52,7 @@ public void tryLoad(File pack) {
try {
ResourcePack resourcePack = null;

if (!pack.isDirectory()) { //directory resource packs temporarily unsupported
if (!pack.isDirectory()) {
switch (Files.getFileExtension(pack.getName())) {
case "zip":
case "mcpack":
Expand All @@ -53,6 +63,12 @@ public void tryLoad(File pack) {
.translate("nukkit.resources.unknown-format", pack.getName()));
break;
}
} else {
File tempPack = loadDirectoryPack(pack);
if (tempPack == null) {
return;
}
resourcePack = new ZippedResourcePack(tempPack);
}

if (resourcePack != null) {
Expand All @@ -77,6 +93,39 @@ public void tryLoad(File pack) {
}
}

@Nullable
private static File loadDirectoryPack(File directory) {
File manifestFile = new File(directory, "manifest.json");
if (!manifestFile.exists() || !manifestFile.isFile()) {
return null;
}

File tempFile;
try {
tempFile = File.createTempFile("pack", ".zip");
tempFile.deleteOnExit();

FileTime time = FileTime.fromMillis(0);
try (ZipOutputStream stream = new ZipOutputStream(new FileOutputStream(tempFile))) {
stream.setLevel(Deflater.BEST_COMPRESSION);

Collection<File> files = new TreeSet<>(FileUtils.listFiles(directory, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE));
for (File file : files) {
ZipEntry entry = new ZipEntry(directory.toPath().relativize(file.toPath()).toString())
.setCreationTime(time)
.setLastModifiedTime(time)
.setLastAccessTime(time);
stream.putNextEntry(entry);
stream.write(Files.toByteArray(file));
stream.closeEntry();
}
}
} catch (IOException e) {
throw new RuntimeException("Unable to create temporary mcpack file", e);
}
return tempFile;
}

public Map<String, ResourcePack> getResourcePacksMap() {
return resourcePacksById;
}
Expand Down
161 changes: 141 additions & 20 deletions src/main/java/cn/nukkit/resourcepacks/ZippedResourcePack.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,167 @@
import cn.nukkit.Server;
import cn.nukkit.math.Mth;
import cn.nukkit.resourcepacks.PackManifest.Module;

import cn.nukkit.utils.Hash;
import cn.nukkit.utils.JsonUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.io.Files;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.RandomStringUtils;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.charset.StandardCharsets;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Optional;
import java.util.*;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import static cn.nukkit.SharedConstants.*;

@Log4j2
public class ZippedResourcePack extends AbstractResourcePack {
private final int size;
private final byte[][] chunks;
private final byte[] sha256;

private final String encryptionKey;

public ZippedResourcePack(File file) throws IOException {
this(file, true);
}

public ZippedResourcePack(File file, boolean encrypt) throws IOException {
if (!file.exists()) {
throw new IllegalArgumentException(Server.getInstance().getLanguage()
.translate("nukkit.resources.zip.not-found", file.getName()));
}

byte[] bytes = Files.toByteArray(file);
try (ZipFile zip = new ZipFile(file)) {
ZipEntry entry = Optional.ofNullable(zip.getEntry("manifest.json"))
ZipEntry manifestEntry = Optional.ofNullable(zip.getEntry("manifest.json"))
.orElse(zip.getEntry("pack_manifest.json"));

if (entry == null) {
if (manifestEntry == null) {
throw new IllegalArgumentException(Server.getInstance().getLanguage()
.translate("nukkit.resources.zip.no-manifest"));
}

manifest = PackManifest.load(zip.getInputStream(entry));
}
manifest = PackManifest.load(zip.getInputStream(manifestEntry));

if (!manifest.isValid()) {
throw new IllegalArgumentException(Server.getInstance().getLanguage()
.translate("nukkit.resources.zip.invalid-manifest"));
}

id = manifest.getHeader().getUuid().toString();
version = manifest.getHeader().getVersion().toString();
type = manifest.getModules().stream()
.findFirst()
.map(Module::getType)
.orElse("resources");
if (!manifest.isValid()) {
throw new IllegalArgumentException(Server.getInstance().getLanguage()
.translate("nukkit.resources.zip.invalid-manifest"));
}

byte[] bytes = Files.readAllBytes(file.toPath());
id = manifest.getHeader().getUuid().toString();
version = manifest.getHeader().getVersion().toString();
type = manifest.getModules().stream()
.findFirst()
.map(Module::getType)
.orElse("resources");

String encryptionKey;
if (RESOURCE_PACK_ENCRYPTION && encrypt) {
Random random = new Random(Hash.xxh64(bytes));
encryptionKey = generateToken(random);

FileTime time = FileTime.fromMillis(0);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
zos.setLevel(Deflater.BEST_COMPRESSION);

List<Map<String, String>> pairs = new ArrayList<>();
Iterator<? extends ZipEntry> iterator = zip.entries().asIterator();
while (iterator.hasNext()) {
ZipEntry entry = iterator.next();
String name = entry.getName().replace('\\', '/');
byte[] content = zip.getInputStream(entry).readAllBytes();

if (name.endsWith(".json") || name.endsWith(".material")) {
try {
JsonNode root = JsonUtil.TRUSTED_JSON_MAPPER.readTree(content);
// minimize JSON file content
content = JsonUtil.TRUSTED_JSON_MAPPER.writeValueAsBytes(root);
} catch (Exception ignored) {
}
} else if (name.startsWith("subpacks/")) {
//TODO: sub-packs
}

byte[] data;
if (!"manifest.json".equalsIgnoreCase(name)
&& !"pack_icon.png".equalsIgnoreCase(name)
&& !"bug_pack_icon.png".equalsIgnoreCase(name)) {
String token = generateToken(random);
byte[] encodedToken = token.getBytes(StandardCharsets.UTF_8);
SecretKeySpec secretKey = new SecretKeySpec(encodedToken, "AES");
Cipher cipher = Cipher.getInstance("AES/CFB8/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(Arrays.copyOf(encodedToken, 16)));
data = cipher.doFinal(content);

Map<String, String> pair = new HashMap<>();
pair.put("path", name);
pair.put("key", token);
pairs.add(pair);
} else {
data = content;

pairs.add(Collections.singletonMap("path", name));
}

zos.putNextEntry(new ZipEntry(entry)
.setCreationTime(time)
.setLastModifiedTime(time)
.setLastAccessTime(time));
zos.write(data);
zos.closeEntry();
}

zos.putNextEntry(new ZipEntry("contents.json")
.setCreationTime(time)
.setLastModifiedTime(time)
.setLastAccessTime(time));

DataOutputStream dos = new DataOutputStream(zos);
dos.writeInt(0); // version
dos.writeInt(0xfcb9cf9b); // magic
dos.writeLong(0);

byte[] encodedId = id.getBytes(StandardCharsets.UTF_8);
int length = encodedId.length;
zos.write(length);
zos.write(encodedId);
zos.write(new byte[256 - (4 + 4 + 8 + 1 + length)]);

byte[] encodedKey = encryptionKey.getBytes(StandardCharsets.UTF_8);
SecretKeySpec secretKey = new SecretKeySpec(encodedKey, "AES");
Cipher cipher = Cipher.getInstance("AES/CFB8/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(Arrays.copyOf(encodedKey, 16)));
String json = JsonUtil.TRUSTED_JSON_MAPPER.writeValueAsString(Collections.singletonMap("content", pairs));
zos.write(cipher.doFinal(json.getBytes(StandardCharsets.UTF_8)));

zos.closeEntry();

zos.finish();
bytes = baos.toByteArray();
} catch (Exception e) {
encryptionKey = "";
log.error("Unable to encrypt mcpack", e);
}
} else {
encryptionKey = "";
}
this.encryptionKey = encryptionKey;
}
size = bytes.length;

int count = Mth.ceil(size / (float) RESOURCE_PACK_CHUNK_SIZE);
Expand Down Expand Up @@ -92,4 +204,13 @@ public byte[] getPackChunk(int index) {

return chunks[index];
}

@Override
public String getEncryptionKey() {
return this.encryptionKey;
}

private static String generateToken(Random random) {
return RandomStringUtils.random(32, 0, 0, true, true, null, random);
}
}

0 comments on commit c4df1d6

Please sign in to comment.