9
0
mirror of https://github.com/Xiao-MoMi/craft-engine.git synced 2025-12-28 11:29:17 +00:00

实现一次性token

This commit is contained in:
XiaoMoMi
2025-04-17 02:45:34 +08:00
parent 716efea8a7
commit d54c14f66a
5 changed files with 239 additions and 122 deletions

View File

@@ -143,7 +143,6 @@ public abstract class AbstractPackManager implements PackManager {
@Override
public void load() {
this.calculateHash();
}
@Override

View File

@@ -3,4 +3,8 @@ package net.momirealms.craftengine.core.pack.host;
import java.util.UUID;
public record ResourcePackDownloadData(String url, UUID uuid, String sha1) {
public static ResourcePackDownloadData of(String url, UUID uuid, String sha1) {
return new ResourcePackDownloadData(url, uuid, sha1);
}
}

View File

@@ -9,18 +9,24 @@ import java.util.concurrent.CompletableFuture;
public class SelfHost implements ResourcePackHost {
public SelfHost(String ip, int port) {
SelfHostHttpServer.instance().setIp(ip);
SelfHostHttpServer.instance().updatePort(port);
}
@Override
public CompletableFuture<ResourcePackDownloadData> requestResourcePackDownloadLink(UUID player) {
return null;
return CompletableFuture.completedFuture(SelfHostHttpServer.instance().generateOneTimeUrl(player));
}
@Override
public ResourcePackDownloadData getResourcePackDownloadLink(UUID player) {
return null;
return SelfHostHttpServer.instance().getCachedOneTimeUrl(player);
}
@Override
public CompletableFuture<Boolean> upload(Path resourcePackPath) {
return null;
SelfHostHttpServer.instance().setResourcePackPath(resourcePackPath);
return CompletableFuture.completedFuture(true);
}
}

View File

@@ -5,40 +5,82 @@ import com.github.benmanes.caffeine.cache.Caffeine;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import net.momirealms.craftengine.core.pack.host.ResourcePackDownloadData;
import net.momirealms.craftengine.core.plugin.CraftEngine;
import net.momirealms.craftengine.core.plugin.config.Config;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class SelfHostHttpServer {
private static SelfHostHttpServer instance;
private Cache<UUID, String> oneTimePackUrls = Caffeine.newBuilder()
private final Cache<String, Boolean> oneTimePackUrls = Caffeine.newBuilder()
.maximumSize(256)
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
private final Cache<UUID, String> playerTokens = Caffeine.newBuilder()
.maximumSize(256)
.expireAfterAccess(1, TimeUnit.MINUTES)
.build();
private final Cache<String, IpAccessRecord> ipAccessCache = Caffeine.newBuilder()
.maximumSize(256)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build();
private final ExecutorService threadPool = Executors.newFixedThreadPool(1);
private HttpServer server;
private final ConcurrentHashMap<String, IpAccessRecord> ipAccessMap = new ConcurrentHashMap<>();
private final AtomicLong totalRequests = new AtomicLong();
private final AtomicLong blockedRequests = new AtomicLong();
private int rateLimit = 1;
private long rateLimitInterval = 1000;
private String ip = "localhost";
private int port = -1;
private Path resourcePackPath;
private volatile byte[] resourcePackBytes;
private String packHash;
private UUID packUUID;
public String generateOneTimeUrl(UUID player) {
public void setIp(String ip) {
this.ip = ip;
}
@NotNull
public ResourcePackDownloadData generateOneTimeUrl(UUID player) {
String token = UUID.randomUUID().toString();
this.oneTimePackUrls.put(token, true);
this.playerTokens.put(player, token);
return new ResourcePackDownloadData(
url() + "download?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
packUUID,
packHash
);
}
@Nullable
public ResourcePackDownloadData getCachedOneTimeUrl(UUID player) {
String token = this.playerTokens.getIfPresent(player);
if (token == null) return null;
return new ResourcePackDownloadData(
url() + "download?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8),
packUUID,
packHash
);
}
public String url() {
@@ -46,51 +88,200 @@ public class SelfHostHttpServer {
}
public void setResourcePackPath(Path resourcePackPath) {
this.resourcePackPath = resourcePackPath;
try {
if (Files.exists(resourcePackPath)) {
this.resourcePackBytes = Files.readAllBytes(resourcePackPath);
calculateHash();
} else {
CraftEngine.instance().logger().warn("Resource pack file not found: " + resourcePackPath);
}
} catch (IOException e) {
CraftEngine.instance().logger().severe("Failed to load resource pack", e);
}
}
private void calculateHash() {
if (Files.exists(this.resourcePackPath)) {
try {
this.packHash = computeSHA1(this.resourcePackPath);
this.packUUID = UUID.nameUUIDFromBytes(this.packHash.getBytes(StandardCharsets.UTF_8));
} catch (IOException | NoSuchAlgorithmException e) {
CraftEngine.instance().logger().severe("Error calculating resource pack hash", e);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
digest.update(resourcePackBytes);
byte[] hashBytes = digest.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
} else {
this.packHash = "";
this.packUUID = UUID.nameUUIDFromBytes("EMPTY".getBytes(StandardCharsets.UTF_8));
this.packHash = hexString.toString();
this.packUUID = UUID.nameUUIDFromBytes(packHash.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
CraftEngine.instance().logger().severe("SHA-1 algorithm not available", e);
}
}
public void updatePort(int port) {
if (port == this.port) {
return;
}
if (server != null) {
disable();
}
if (port == this.port) return;
if (server != null) disable();
this.port = port;
try {
server = HttpServer.create(new InetSocketAddress("::", port), 0);
server.createContext("/", new ResourcePackHandler());
server.setExecutor(Executors.newCachedThreadPool());
server.createContext("/download", new ResourcePackHandler());
server.createContext("/metrics", this::handleMetrics);
server.setExecutor(threadPool);
server.start();
CraftEngine.instance().logger().info("HTTP resource pack server running on port: " + port);
CraftEngine.instance().logger().info("HTTP server started on port: " + port);
} catch (IOException e) {
CraftEngine.instance().logger().warn("Failed to start HTTP server", e);
}
}
private void handleMetrics(HttpExchange exchange) throws IOException {
String metrics = "# TYPE total_requests counter\n"
+ "total_requests " + totalRequests.get() + "\n"
+ "# TYPE blocked_requests counter\n"
+ "blocked_requests " + blockedRequests.get();
exchange.getResponseHeaders().set("Content-Type", "text/plain");
exchange.sendResponseHeaders(200, metrics.length());
try (OutputStream os = exchange.getResponseBody()) {
os.write(metrics.getBytes(StandardCharsets.UTF_8));
}
}
public void disable() {
if (server != null) {
server.stop(0);
server = null;
threadPool.shutdownNow();
}
}
public boolean isAlive() {
return server != null;
public void adjustRateLimit(int requestsPerSecond, int rateLimitInterval) {
this.rateLimit = requestsPerSecond;
this.rateLimitInterval = rateLimitInterval;
CraftEngine.instance().logger().info("Updated rate limit to " + requestsPerSecond + "/s");
}
private class ResourcePackHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
totalRequests.incrementAndGet();
String clientIp = getClientIp(exchange);
if (checkRateLimit(clientIp)) {
handleBlockedRequest(exchange, 429, "Rate limit exceeded");
return;
}
String token = parseToken(exchange);
if (!validateToken(token)) {
handleBlockedRequest(exchange, 403, "Invalid token");
return;
}
if (!validateClient(exchange)) {
handleBlockedRequest(exchange, 403, "Invalid client");
return;
}
if (resourcePackBytes == null) {
handleBlockedRequest(exchange, 404, "Resource pack missing");
return;
}
sendResourcePack(exchange);
}
private String getClientIp(HttpExchange exchange) {
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
private boolean checkRateLimit(String clientIp) {
IpAccessRecord record = ipAccessCache.getIfPresent(clientIp);
long now = System.currentTimeMillis();
if (record == null) {
record = new IpAccessRecord(now, 1);
ipAccessCache.put(clientIp, record);
} else {
if (now - record.lastAccessTime > rateLimitInterval) {
record = new IpAccessRecord(now, 1);
ipAccessCache.put(clientIp, record);
} else {
record.accessCount++;
}
}
return record.accessCount > rateLimit;
}
private String parseToken(HttpExchange exchange) {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
return params.get("token");
}
private boolean validateToken(String token) {
if (token == null || token.length() != 36) return false;
Boolean valid = oneTimePackUrls.getIfPresent(token);
if (valid != null) {
oneTimePackUrls.invalidate(token);
return true;
}
return false;
}
private boolean validateClient(HttpExchange exchange) {
if (!Config.denyNonMinecraftRequest()) return true;
String userAgent = exchange.getRequestHeaders().getFirst("User-Agent");
return userAgent != null && userAgent.startsWith("Minecraft Java/");
}
private void sendResourcePack(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().set("Content-Type", "application/zip");
exchange.getResponseHeaders().set("Content-Length", String.valueOf(resourcePackBytes.length));
exchange.sendResponseHeaders(200, resourcePackBytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(resourcePackBytes);
} catch (IOException e) {
CraftEngine.instance().logger().warn("Failed to send resource pack", e);
throw e;
}
}
private void handleBlockedRequest(HttpExchange exchange, int code, String reason) throws IOException {
blockedRequests.incrementAndGet();
CraftEngine.instance().debug(() ->
String.format("Blocked request [%s] %s: %s",
code,
exchange.getRemoteAddress(),
reason)
);
exchange.sendResponseHeaders(code, -1);
exchange.close();
}
private Map<String, String> parseQuery(String query) {
Map<String, String> params = new HashMap<>();
if (query == null) return params;
for (String pair : query.split("&")) {
int idx = pair.indexOf("=");
String key = idx > 0 ? pair.substring(0, idx) : pair;
String value = idx > 0 ? pair.substring(idx + 1) : "";
params.put(key, value);
}
return params;
}
}
private static class IpAccessRecord {
final long lastAccessTime;
int accessCount;
IpAccessRecord(long lastAccessTime, int accessCount) {
this.lastAccessTime = lastAccessTime;
this.accessCount = accessCount;
}
}
public static SelfHostHttpServer instance() {
@@ -99,93 +290,4 @@ public class SelfHostHttpServer {
}
return instance;
}
public void setRateLimit(int rateLimit, long rateLimitInterval, TimeUnit timeUnit) {
this.rateLimit = rateLimit;
this.rateLimitInterval = timeUnit.toMillis(rateLimitInterval);
}
public void setIp(String ip) {
this.ip = ip;
}
private String computeSHA1(Path path) throws IOException, NoSuchAlgorithmException {
InputStream file = Files.newInputStream(path);
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = file.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
file.close();
StringBuilder hexString = new StringBuilder(40);
for (byte b : digest.digest()) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
private class ResourcePackHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if (Config.denyNonMinecraftRequest()) {
String userAgent = exchange.getRequestHeaders().getFirst("User-Agent");
if (userAgent == null || !userAgent.startsWith("Minecraft Java/")) {
CraftEngine.instance().debug(() -> "Blocked non-Minecraft Java client. User-Agent: " + userAgent);
sendError(exchange, 403);
return;
}
}
String clientIp = exchange.getRemoteAddress().getAddress().getHostAddress();
IpAccessRecord record = ipAccessMap.compute(clientIp, (k, v) -> {
long currentTime = System.currentTimeMillis();
if (v == null || currentTime - v.lastAccessTime > rateLimitInterval) {
return new IpAccessRecord(currentTime, 1);
} else {
v.accessCount++;
return v;
}
});
if (record.accessCount > rateLimit) {
CraftEngine.instance().debug(() -> "Rate limit exceeded for IP: " + clientIp);
sendError(exchange, 429);
return;
}
if (!Files.exists(resourcePackPath)) {
CraftEngine.instance().logger().warn("ResourcePack not found: " + resourcePackPath);
sendError(exchange, 404);
return;
}
exchange.getResponseHeaders().set("Content-Type", "application/zip");
exchange.getResponseHeaders().set("Content-Length", String.valueOf(Files.size(resourcePackPath)));
exchange.sendResponseHeaders(200, Files.size(resourcePackPath));
try (OutputStream os = exchange.getResponseBody()) {
Files.copy(resourcePackPath, os);
} catch (IOException e) {
CraftEngine.instance().logger().warn("Failed to send pack", e);
}
}
private void sendError(HttpExchange exchange, int code) throws IOException {
exchange.sendResponseHeaders(code, 0);
exchange.getResponseBody().close();
}
}
private static class IpAccessRecord {
long lastAccessTime;
int accessCount;
IpAccessRecord(long lastAccessTime, int accessCount) {
this.lastAccessTime = lastAccessTime;
this.accessCount = accessCount;
}
}
}

View File

@@ -3,6 +3,7 @@ package net.momirealms.craftengine.core.pack.host.impl;
import net.momirealms.craftengine.core.pack.host.ResourcePackDownloadData;
import net.momirealms.craftengine.core.pack.host.ResourcePackHost;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -22,4 +23,9 @@ public class SimpleExternalHost implements ResourcePackHost {
public ResourcePackDownloadData getResourcePackDownloadLink(UUID player) {
return this.downloadData;
}
@Override
public CompletableFuture<Boolean> upload(Path resourcePackPath) {
return CompletableFuture.completedFuture(true);
}
}