implement GitHub resource pack auto-updater with HTTP server and configuration

This commit is contained in:
Lars Behrends
2025-12-30 00:17:46 +01:00
parent 3305388f1c
commit e0dbca8d51
6 changed files with 432 additions and 5 deletions

View File

@@ -1,10 +1,11 @@
# Projectvollidioten
A Minecraft Fabric mod that logs player data on join and leave events to an external API.
A Minecraft Fabric mod that provides comprehensive player data logging and automatic resource pack management via GitHub integration.
## Features
- Logs player join and leave events
### Player Data Logging
- Logs player join and leave events to external API
- Captures detailed player statistics including:
- Health and max health
- Hunger level
@@ -15,6 +16,15 @@ A Minecraft Fabric mod that logs player data on join and leave events to an exte
- Asynchronous API communication to prevent server lag
- Pretty-printed JSON logging for debugging
### Automatic Resource Pack Updates
- **GitHub Integration**: Automatically monitors GitHub releases for new resource packs
- **HTTP Server**: Built-in HTTP server (port 25585) to serve resource packs locally
- **Smart Updates**: Downloads and installs new resource pack versions automatically
- **Server Configuration**: Automatically updates `server.properties` with correct URLs and SHA1 hashes
- **Player Notifications**: Broadcasts resource pack availability to all players
- **Periodic Checking**: Configurable interval checking (default: every 60 minutes)
- **Secure Serving**: Only serves configured resource pack files for security
## Requirements
- Minecraft 1.21.11
@@ -30,10 +40,42 @@ A Minecraft Fabric mod that logs player data on join and leave events to an exte
## Configuration
### Player Data Logging
The mod sends data to a hardcoded API endpoint: `http://localhost:3000/api/data`
To change the API endpoint, modify the `API_URL` constant in `PlayerDataLogger.java`.
### Resource Pack Auto-Updater
The resource pack updater is configured via `config/projectvollidioten/resourcepack.json`:
```json
{
"githubOwner": "your-username",
"githubRepo": "your-repo-name",
"resourcePackFileName": "my-pack.zip",
"checkIntervalMinutes": 60
}
```
**Configuration Options:**
- `githubOwner`: Your GitHub username or organization name
- `githubRepo`: The repository name containing the resource pack releases
- `resourcePackFileName`: The exact filename of the resource pack ZIP in GitHub releases
- `checkIntervalMinutes`: How often to check for updates (default: 60 minutes)
**Setup Instructions:**
1. Create a GitHub repository for your resource packs
2. Upload resource pack ZIP files as release assets
3. Update the configuration file with your repository details
4. The mod will automatically download and serve new versions
5. Players will be notified when new packs are available
**Important Notes:**
- The built-in HTTP server runs on port 25585 by default
- Resource packs are stored in `server-resource-packs/` directory
- Only the configured filename can be served for security
- SHA1 hashes are automatically computed and updated in server.properties
## API Format
The mod sends POST requests with JSON payloads in the following format:

View File

@@ -0,0 +1,226 @@
package ceratic.projectvollidioten;
import ceratic.projectvollidioten.config.GithubResourcePackConfig;
import ceratic.projectvollidioten.http.ResourcePackHttpServer;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.dedicated.DedicatedServer;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipFile;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import static ceratic.projectvollidioten.config.GithubResourcePackConfig.loadOrCreate;
public class GithubResourcePackUpdater {
private ResourcePackHttpServer httpServer;
private final MinecraftServer server;
private final Path configDir;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private GithubResourcePackConfig config;
private String latestVersionTag = "";
private String downloadUrl = "";
public GithubResourcePackUpdater(MinecraftServer server) {
this.server = server;
this.configDir = server.getRunDirectory().resolve("config/projectvollidioten");
this.config = loadOrCreate(configDir);
}
public void start() {
// Start HTTP server FIRST (so we have URL ready)
try {
httpServer = new ResourcePackHttpServer(server, config.resourcePackFileName, 25585); // Use port 25585 for now
httpServer.start();
} catch (IOException e) {
System.err.println("[GithubRP] ❌ Failed to start HTTP server: " + e.getMessage());
}
// Schedule periodic checks
long interval = config.checkIntervalMinutes;
scheduler.scheduleAtFixedRate(this::checkForUpdate, 0, interval, TimeUnit.MINUTES);
System.out.println("[GithubRP] Auto-updater started. Checking every " + interval + " minutes.");
}
public void stop() {
if (httpServer != null) httpServer.stop();
scheduler.shutdown();
System.out.println("[GithubRP] Auto-updater stopped.");
}
private void checkForUpdate() {
try {
String apiUrl = String.format(
"https://api.github.com/repos/%s/%s/releases/latest",
config.githubOwner, config.githubRepo
);
HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection();
conn.setRequestProperty("User-Agent", "FabricMod-GithubRPUpdater");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(15_000);
int responseCode = conn.getResponseCode();
if (responseCode != 200) {
System.err.println("[GithubRP] Failed to fetch release: HTTP " + responseCode);
conn.disconnect();
return;
}
JsonObject release = JsonParser.parseReader(new InputStreamReader(conn.getInputStream())).getAsJsonObject();
conn.disconnect();
String tag = release.get("tag_name").getAsString();
if (tag.equals(latestVersionTag)) {
// Already up to date
return;
}
JsonArray assets = release.getAsJsonArray("assets");
for (var assetElem : assets) {
JsonObject asset = assetElem.getAsJsonObject();
String name = asset.get("name").getAsString();
if (name.equals(config.resourcePackFileName)) {
downloadUrl = asset.get("browser_download_url").getAsString();
downloadAndInstallResourcePack(downloadUrl, tag);
latestVersionTag = tag;
return;
}
}
System.err.println("[GithubRP] Release " + tag + " found, but no asset named '" + config.resourcePackFileName + "'");
} catch (Exception e) {
System.err.println("[GithubRP] Error checking for update: " + e.getMessage());
e.printStackTrace();
}
}
private void downloadAndInstallResourcePack(String downloadUrl, String tag) throws IOException {
Path packsDir = server.getRunDirectory().resolve("server-resource-packs");
Files.createDirectories(packsDir);
Path tempFile = Files.createTempFile("github-rp-", ".zip");
Path targetFile = packsDir.resolve(config.resourcePackFileName);
try {
// Download
System.out.println("[GithubRP] Downloading new resource pack: " + tag);
HttpURLConnection conn = (HttpURLConnection) new URL(downloadUrl).openConnection();
conn.setRequestProperty("User-Agent", "FabricMod-GithubRPUpdater");
try (InputStream in = conn.getInputStream()) {
Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
// Validate ZIP (basic check)
try (ZipFile ignored = new ZipFile(tempFile.toFile())) {
// OK if no exception
} catch (Exception e) {
throw new IOException("Downloaded file is not a valid ZIP: " + e.getMessage());
}
// Replace old pack
Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
updateServerPropertiesWithHttpUrl(targetFile); // ✅ Now uses local HTTP URL!
// Update server.properties to point to the new pack
// updateServerProperties(targetFile);
// Update server.properties if needed (optional — Fabric handles reload via packet)
if (server instanceof DedicatedServer dedicated) {
// Force reload of resource packs (Fabric supports dynamic reload)
// Well push updated pack hash to clients on next join via login packet
System.out.println("[GithubRP] Resource pack updated to " + tag + ". New clients will get it.");
}
} finally {
try { Files.deleteIfExists(tempFile); } catch (IOException ignored) {}
}
}
private void updateServerPropertiesWithHttpUrl(Path packPath) {
try {
String sha1 = computeSHA1(packPath);
if (sha1 == null) return;
String url = (httpServer != null)
? httpServer.getPackUrl()
: "http://127.0.0.1:25585/resourcepacks/" + config.resourcePackFileName;
// Update server.properties
Path serverProps = server.getRunDirectory().resolve("server.properties");
if (!Files.exists(serverProps)) return;
List<String> lines = Files.readAllLines(serverProps);
List<String> newLines = new ArrayList<>();
boolean foundRp = false, foundSha = false;
for (String line : lines) {
String t = line.trim();
if (t.startsWith("resource-pack=")) {
line = "resource-pack=" + url + (line.contains("#") ? " " + line.substring(line.indexOf('#')) : "");
foundRp = true;
} else if (t.startsWith("resource-pack-sha1=")) {
line = "resource-pack-sha1=" + sha1 + (line.contains("#") ? " " + line.substring(line.indexOf('#')) : "");
foundSha = true;
}
newLines.add(line);
}
if (!foundRp) newLines.add("resource-pack=" + url);
if (!foundSha) newLines.add("resource-pack-sha1=" + sha1);
Path tmp = serverProps.resolveSibling("server.properties.tmp");
Files.write(tmp, newLines);
Files.move(tmp, serverProps, StandardCopyOption.REPLACE_EXISTING);
System.out.println("[GithubRP] ✅ server.properties updated:");
System.out.println(" URL: " + url);
System.out.println(" SHA1: " + sha1.substring(0, 8) + "...");
// Optional: Broadcast to all players
server.execute(() -> {
String shortUrl = url.replace("http://", "").replace("https://", "");
server.getPlayerManager().getPlayerList().forEach(p -> {
p.sendMessage(net.minecraft.text.Text.literal("[GithubRP] 🌐 Pack served at " + shortUrl), false);
});
});
} catch (Exception e) {
System.err.println("[GithubRP] Failed to update server.properties with HTTP URL: " + e.getMessage());
}
}
private String computeSHA1(Path file) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
try (InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int n;
while ((n = in.read(buffer)) != -1) {
digest.update(buffer, 0, n);
}
}
byte[] hash = digest.digest();
StringBuilder hex = new StringBuilder();
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

View File

@@ -1,7 +1,9 @@
package ceratic.projectvollidioten;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.MinecraftServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,6 +16,8 @@ public class Projectvollidioten implements ModInitializer {
// That way, it's clear which mod wrote info, warnings, and errors.
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
private GithubResourcePackUpdater updater;
@Override
public void onInitialize() {
// This code runs as soon as Minecraft is in a mod-load-ready state.
@@ -22,6 +26,9 @@ public class Projectvollidioten implements ModInitializer {
LOGGER.info("Hello Fabric world!");
ServerLifecycleEvents.SERVER_STARTED.register(this::onServerStart);
ServerLifecycleEvents.SERVER_STOPPING.register(this::onServerStop);
// Event: Player Join
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
PlayerDataLogger.sendPlayerData(handler.getPlayer(), server, "JOIN");
@@ -32,4 +39,15 @@ public class Projectvollidioten implements ModInitializer {
PlayerDataLogger.sendPlayerData(handler.getPlayer(), server, "LEAVE");
});
}
private void onServerStart(MinecraftServer server) {
updater = new GithubResourcePackUpdater(server);
updater.start();
}
private void onServerStop(MinecraftServer server) {
if (updater != null) {
updater.stop();
}
}
}

View File

@@ -0,0 +1,40 @@
package ceratic.projectvollidioten.config;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
public class GithubResourcePackConfig {
public String githubOwner = "your-username";
public String githubRepo = "your-repo";
public String resourcePackFileName = "my-pack.zip";
public int checkIntervalMinutes = 60; // check every hour
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String CONFIG_NAME = "resourcepack.json";
public static GithubResourcePackConfig loadOrCreate(Path configDir) {
Path configFile = configDir.resolve(CONFIG_NAME);
try {
if (Files.exists(configFile)) {
try (Reader reader = Files.newBufferedReader(configFile)) {
return GSON.fromJson(reader, GithubResourcePackConfig.class);
}
} else {
// Create default config
GithubResourcePackConfig defaultConfig = new GithubResourcePackConfig();
Files.createDirectories(configDir);
try (Writer writer = Files.newBufferedWriter(configFile)) {
GSON.toJson(defaultConfig, writer);
}
return defaultConfig;
}
} catch (Exception e) {
System.err.println("[GithubRP] Failed to load config, using defaults: " + e.getMessage());
return new GithubResourcePackConfig();
}
}
}

View File

@@ -0,0 +1,101 @@
package ceratic.projectvollidioten.http;
import ceratic.projectvollidioten.config.GithubResourcePackConfig;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import net.minecraft.server.MinecraftServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
public class ResourcePackHttpServer {
private final HttpServer server;
private final Path resourcePacksDir;
private final String expectedFileName;
private final int port;
public ResourcePackHttpServer(MinecraftServer mcServer, String expectedFileName, int port) throws IOException {
this.resourcePacksDir = mcServer.getRunDirectory().resolve("server-resource-packs");
this.expectedFileName = expectedFileName;
this.port = port;
Files.createDirectories(resourcePacksDir);
this.server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0);
this.server.createContext("/resourcepacks", new ResourcePackHandler());
this.server.setExecutor(null); // Uses default executor
}
public void start() {
server.start();
System.out.println("[GithubRP] 🌐 HTTP server started on http://0.0.0.0:" + port + "/resourcepacks/");
}
public void stop() {
server.stop(1);
System.out.println("[GithubRP] 🌐 HTTP server stopped.");
}
// Returns URL clients should use — e.g. http://192.168.1.100:25585/resourcepacks/my-pack.zip
public String getPackUrl() {
// Try to get LAN IP (optional: replace with public IP if port-forwarded)
String host = "127.0.0.1"; // fallback
try {
java.net.InetAddress addr = java.net.InetAddress.getLocalHost();
if (!addr.isLoopbackAddress() && addr.isSiteLocalAddress()) {
host = addr.getHostAddress();
}
} catch (Exception ignored) {}
return String.format("http://%s:%d/resourcepacks/%s", host, port, expectedFileName);
}
private class ResourcePackHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
String targetFile = path.substring("/resourcepacks/".length());
// Security: only allow configured pack filename
if (!targetFile.equals(expectedFileName)) {
sendResponse(exchange, 403, "Forbidden: unknown resource pack");
return;
}
Path packFile = resourcePacksDir.resolve(targetFile);
if (!Files.exists(packFile) || !Files.isRegularFile(packFile)) {
sendResponse(exchange, 404, "Resource pack not found");
return;
}
// Set headers
exchange.getResponseHeaders().set("Content-Type", "application/zip");
exchange.getResponseHeaders().set("Content-Length", String.valueOf(Files.size(packFile)));
exchange.getResponseHeaders().set("Cache-Control", "public, max-age=3600");
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*");
// Send file
exchange.sendResponseHeaders(200, 0); // chunked
try (OutputStream os = exchange.getResponseBody();
var fis = Files.newInputStream(packFile)) {
fis.transferTo(os);
} catch (IOException e) {
System.err.println("[GithubRP] Client disconnected during pack download.");
}
}
private void sendResponse(HttpExchange exchange, int code, String msg) throws IOException {
byte[] data = msg.getBytes(java.nio.charset.StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
exchange.sendResponseHeaders(code, data.length);
exchange.getResponseBody().write(data);
exchange.close();
}
}
}

View File

@@ -3,13 +3,13 @@
"id": "projectvollidioten",
"version": "${version}",
"name": "projectvollidioten",
"description": "This is an example description! Tell everyone what your mod is about!",
"description": "A Minecraft Fabric mod that provides comprehensive player data logging and automatic resource pack management via GitHub integration.",
"authors": [
"Me!"
],
"contact": {
"homepage": "https://fabricmc.net/",
"sources": "https://github.com/FabricMC/fabric-example-mod"
"homepage": "https://vollidioten.ceraticsoft.de/",
"sources": "https://github.com/ceratic/projekt_vollidion_mod"
},
"license": "CC0-1.0",
"icon": "assets/projectvollidioten/icon.png",