Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ On first start, the mod generates a configuration file at `<server_root_dir>/con
```yaml
# The main configuration file for Resource Pack Local Path.

# Whether local resource-pack hosting (resource-pack= pointing to a relative
# file path instead of a URL in server.properties) is enabled at all.
# @default: true
resource-pack-hosting-enabled: true
# The host/IP that will be advertised to clients when downloading a locally
# The host/IP that will be used for clients when downloading a locally
# hosted resource-pack. This must be reachable from the client's network
# (your public IP/domain, with port forwarding if needed) — the mod cannot
# (your public IP/domain, with port forwarding if needed) - the mod cannot
# detect this automatically.
#
# The port used for hosting is the same as the server's port.
# @default: '127.0.0.1'
resource-pack-host: 127.0.0.1
host: 127.0.0.1
# Whether resource-pack= pointing to a directory (instead of a .zip file)
# should automatically be zipped on the fly and cached.
# @default: true
auto-zip: true
# Directory (relative to the server root, or absolute) in which cached
# zipped resource packs are stored.
# @default: './config/resource_pack_local_path/cache'
cache-directory: ./config/resource_pack_local_path/cache
```
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ loom_version=1.16-SNAPSHOT
fabric_api_version=0.145.1+26.1

# Mod Properties
mod_version=0.1.1
mod_version=1.0.0
maven_group=dev.loat
archives_base_name=resource-pack-local-path

Expand Down
11 changes: 11 additions & 0 deletions src/main/java/dev/loat/resource_pack_local_path/config/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@ public class Config {

private Config() {}

/** The root directory for the configuration files. */
private static final String ROOT_DIRECTORY = "resource_pack_local_path";
/** The ConfigManager instance for managing configuration files. */
private static final ConfigManager CONFIG_MANAGER = new ConfigManager(ROOT_DIRECTORY);

/**
* Registers the configuration files with the ConfigManager.
* This method should be called during the mod initialization phase.
*/
public static void register() {

CONFIG_MANAGER.add("config.yml", ResourcePackLocalPathConfigFile.class);
}

/**
* Retrieves the ResourcePackLocalPathConfigFile instance from the ConfigManager.
*
* @return The ResourcePackLocalPathConfigFile instance.
*/
public static ResourcePackLocalPathConfigFile getConfig() {

return CONFIG_MANAGER.get(ResourcePackLocalPathConfigFile.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@
public class ResourcePackLocalPathConfigFile {
private ResourcePackLocalPathConfigFile() {}

@Annotation.Comment("""
Whether local resource-pack hosting (resource-pack= pointing to a relative
file path instead of a URL in server.properties) is enabled at all.
""")
@Annotation.Key("resource-pack-hosting-enabled")
public boolean resourcePackHostingEnabled = true;

@Annotation.Comment("""
The host/IP that will be used for clients when downloading a locally
hosted resource-pack. This must be reachable from the client's network
Expand All @@ -24,6 +17,20 @@ private ResourcePackLocalPathConfigFile() {}

The port used for hosting is the same as the server's port.
""")
@Annotation.Key("resource-pack-host")
public String resourcePackHost = "127.0.0.1";
@Annotation.Key("host")
public String host = "127.0.0.1";

@Annotation.Comment("""
Whether resource-pack= pointing to a directory (instead of a .zip file)
should automatically be zipped on the fly and cached.
""")
@Annotation.Key("auto-zip")
public boolean autoZip = true;

@Annotation.Comment("""
Directory (relative to the server root, or absolute) in which cached
zipped resource packs are stored.
""")
@Annotation.Key("cache-directory")
public String cacheDirectory = "./config/resource_pack_local_path/cache";
}
81 changes: 81 additions & 0 deletions src/main/java/dev/loat/resource_pack_local_path/hashing/Hash.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dev.loat.resource_pack_local_path.hashing;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
* Central hashing helper methods for local resource pack support.
* Bundles SHA-1 calculations for files and arbitrary strings so the
* hex encoding logic is not duplicated across the project.
*/
public final class Hash {

private Hash() {}

/**
* Creates a new SHA-1 {@link MessageDigest} instance.
*
* @return A fresh, unused SHA-1 {@link MessageDigest}.
* @throws IllegalStateException If the SHA-1 algorithm is not available in
* this JVM (practically never the case,
* since SHA-1 is part of every standard JVM).
*/
private static MessageDigest newSha1Digest() {
try {
return MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-1 algorithm not available", e);
}
}

/**
* Converts a byte array to its hexadecimal string representation.
*
* @param bytes The bytes to encode.
* @return The hex string (lowercase, two characters per byte).
*/
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

/**
* Computes the SHA-1 hash of a file's contents.
*
* @param file Path to the file whose contents should be hashed.
* @return The SHA-1 hash as a 40-character lowercase hex string.
* @throws IOException If the file cannot be read.
*/
public static String sha1HexOfFile(Path file) throws IOException {
MessageDigest digest = newSha1Digest();

try (var in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}

return toHex(digest.digest());
}

/**
* Computes the SHA-1 hash of a string (UTF-8 encoded).
*
* @param input The string to hash.
* @return The SHA-1 hash as a 40-character lowercase hex string.
*/
public static String sha1HexOfString(String input) {
MessageDigest digest = newSha1Digest();
digest.update(input.getBytes(StandardCharsets.UTF_8));
return toHex(digest.digest());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package dev.loat.resource_pack_local_path.http;

import dev.loat.resource_pack_local_path.logging.Logger;
import dev.loat.resource_pack_local_path.hashing.Hash;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;


/**
* Zips a resource pack directory when needed.
*
* <p>The cache filename is derived from a fast metadata fingerprint (relative
* paths + size + modification time of each file) so unchanged directories
* can be recognized and re-zipping skipped, without reading file contents.
*
* <p>The returned {@link ZippedResult#contentHash()}, however, is always the
* actual SHA-1 of the finished zip file's bytes (computed via a cheap hash
* pass over the - possibly reused - zip, not by re-zipping). This is what
* Minecraft clients verify the download against via resource-pack-sha1, so
* it must be the real file hash, not just a stable fingerprint of the source
* directory.
*
* <p>Old cache entries (from previous, different directory contents) are not
* automatically cleaned up.
*/
public final class DirectoryResourcePackZipper {

private DirectoryResourcePackZipper() {}

/**
* Computes a fast metadata fingerprint for a directory based on the
* relative path, size, and modification time of each contained file
* (recursive, sorted for deterministic results). The files are
* intentionally not read to keep this fast even for large resource
* packs. Used only to decide the cache filename / whether re-zipping is
* necessary - NOT exposed as the resource-pack-sha1 (see class Javadoc).
*
* @param dir The directory to fingerprint
* @return SHA-1 hex hash of the fingerprint
* @throws IOException If the directory cannot be traversed.
*/
private static String computeCacheKey(Path dir) throws IOException {
List<String> entries = new ArrayList<>();

try (Stream<Path> stream = Files.walk(dir)) {
stream.filter(Files::isRegularFile).sorted().forEach(file -> {
try {
String relativePath = dir.relativize(file).toString().replace('\\', '/');
long size = Files.size(file);
long lastModified = Files.getLastModifiedTime(file).toMillis();
entries.add("%s:%d:%d".formatted(relativePath, size, lastModified));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (UncheckedIOException e) {
throw e.getCause();
}

return Hash.sha1HexOfString(String.join("\n", entries));
}

/**
* Packs all files from a directory recursively into a zip file.
* Writes first to a temporary file and renames it to the target path only
* after successful completion, so incomplete zip files are not left behind
* in case of failure.
*
* @param sourceDir The directory to zip
* @param zipFile Target path for the finished zip file
* @throws IOException If the directory cannot be read or the zip cannot be
* written.
*/
private static void zipDirectory(
Path sourceDir,
Path zipFile
) throws IOException {
Path tempZip = zipFile.resolveSibling("%s.tmp".formatted(zipFile.getFileName()));

try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(tempZip))) {
try (Stream<Path> stream = Files.walk(sourceDir)) {
List<Path> files = stream.filter(Files::isRegularFile).sorted().toList();
for (Path file : files) {
String relativePath = sourceDir.relativize(file).toString().replace('\\', '/');
zipOut.putNextEntry(new ZipEntry(relativePath));
Files.copy(file, zipOut);
zipOut.closeEntry();
}
}
}

Files.move(tempZip, zipFile, StandardCopyOption.REPLACE_EXISTING);
}

/**
* Result of a zip operation.
*
* @param zipFile Path to the finished zip file (newly created or reused
* from cache)
* @param contentHash Actual SHA-1 of the zip file's bytes - safe to use
* directly as resource-pack-sha1
*/
public record ZippedResult(
Path zipFile,
String contentHash
) {}

/**
* Zips the given directory if needed, reusing a cached result if the
* directory's content hasn't changed since the last call.
*
* @param sourceDir The resource pack directory to zip
* @param cacheDir Directory where cached zips are stored (created if
* necessary)
* @return The {@link ZippedResult} with the path to the zip file and the
* actual SHA-1 of its bytes.
* @throws IOException If the directory cannot be read, the zip cannot be
* written, or the resulting zip cannot be hashed.
*/
public static ZippedResult zip(
Path sourceDir,
Path cacheDir
) throws IOException {
String cacheKey = DirectoryResourcePackZipper.computeCacheKey(sourceDir);

Files.createDirectories(cacheDir);
Path zipFile = cacheDir.resolve("%s.zip".formatted(cacheKey));

if (Files.exists(zipFile)) {
Logger.debug("Resource pack unchanged, reusing cached zip: '%s'".formatted(zipFile));
} else {
Logger.info("Resource pack changed (or not cached yet), zipping to: '%s'".formatted(zipFile));
DirectoryResourcePackZipper.zipDirectory(sourceDir, zipFile);
}

String contentHash = Hash.sha1HexOfFile(zipFile);
return new DirectoryResourcePackZipper.ZippedResult(zipFile, contentHash);
}
}
Loading