9
0
mirror of https://github.com/Xiao-MoMi/craft-engine.git synced 2026-01-04 15:41:38 +00:00

feat(core): 添加 Alist 托管支持

This commit is contained in:
jhqwqmc
2025-04-18 09:06:41 +08:00
parent e92e50bb78
commit 7b1f26df0e
4 changed files with 289 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
package net.momirealms.craftengine.bukkit.pack;
import net.momirealms.craftengine.bukkit.api.event.AsyncResourcePackGenerateEvent;
import net.momirealms.craftengine.bukkit.nms.FastNMS;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.bukkit.plugin.command.feature.ReloadCommand;
import net.momirealms.craftengine.bukkit.plugin.user.BukkitServerPlayer;
@@ -153,6 +154,9 @@ public class BukkitPackManager extends AbstractPackManager implements Listener {
player.sendPackets(packets, true);
}
}
}).exceptionally(throwable -> {
CraftEngine.instance().logger().warn("Failed to send resource pack to player " + player.name(), throwable);
return null;
});
}
}

View File

@@ -2132,6 +2132,10 @@ public class PacketConsumers {
user.nettyChannel().writeAndFlush(newPacket);
user.addResourcePackUUID(data.uuid());
}
}).exceptionally(throwable -> {
CraftEngine.instance().logger().warn("Failed to handle ClientboundResourcePackPushPacket", throwable);
user.simulatePacket(FastNMS.INSTANCE.constructor$ServerboundResourcePackPacket$SUCCESSFULLY_LOADED(packUUID));
return null;
});
} catch (Exception e) {
CraftEngine.instance().logger().warn("Failed to handle ClientboundResourcePackPushPacket", e);

View File

@@ -17,6 +17,7 @@ public class ResourcePackHosts {
public static final Key LOBFILE = Key.of("craftengine:lobfile");
public static final Key S3_HOST = Key.of("craftengine:s3_host");
public static final Key CUSTOM_API_HOST = Key.of("craftengine:custom_api_host");
public static final Key ALIST_HOST = Key.of("craftengine:alist_host");
static {
register(NONE, NoneHost.FACTORY);
@@ -25,6 +26,7 @@ public class ResourcePackHosts {
register(LOBFILE, LobFileHost.FACTORY);
register(S3_HOST, S3Host.FACTORY);
register(CUSTOM_API_HOST, CustomApiHost.FACTORY);
register(ALIST_HOST, AlistHost.FACTORY);
}
public static void register(Key key, ResourcePackHostFactory factory) {

View File

@@ -0,0 +1,279 @@
package net.momirealms.craftengine.core.pack.host.impl;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import net.momirealms.craftengine.core.pack.host.ResourcePackDownloadData;
import net.momirealms.craftengine.core.pack.host.ResourcePackHost;
import net.momirealms.craftengine.core.pack.host.ResourcePackHostFactory;
import net.momirealms.craftengine.core.plugin.CraftEngine;
import net.momirealms.craftengine.core.util.GsonHelper;
import net.momirealms.craftengine.core.util.Pair;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
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.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
public class AlistHost implements ResourcePackHost {
public static final Factory FACTORY = new Factory();
private final String apiUrl;
private final String userName;
private final String password;
private final String filePassword;
private final String otpCode;
private final Duration jwtTokenExpiration;
private final String filePath;
private final Path localFilePath;
private Pair<String, Date> jwtToken;
private String cacheSha1;
public AlistHost(String apiUrl,
String userName,
String password,
String filePassword,
String otpCode,
Duration jwtTokenExpiration,
String filePath,
String localFilePath) {
this.apiUrl = apiUrl;
this.userName = userName;
this.password = password;
this.filePassword = filePassword;
this.otpCode = otpCode;
this.jwtTokenExpiration = jwtTokenExpiration;
this.filePath = filePath;
this.localFilePath = localFilePath == null ? null : Path.of(localFilePath);
this.readCacheFromDisk();
}
private void readCacheFromDisk() {
Path cachePath = CraftEngine.instance().dataFolderPath().resolve("alist.cache");
if (!Files.exists(cachePath)) return;
try (InputStream is = Files.newInputStream(cachePath)) {
cacheSha1 = new String(is.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
CraftEngine.instance().logger().warn("[Alist] Failed to read cache file", e);
}
}
private void saveCacheToDisk() {
Path cachePath = CraftEngine.instance().dataFolderPath().resolve("alist.cache");
try {
Files.writeString(cachePath, cacheSha1);
} catch (IOException e) {
CraftEngine.instance().logger().warn("[Alist] Failed to write cache file", e);
}
}
@Override
public CompletableFuture<List<ResourcePackDownloadData>> requestResourcePackDownloadLink(UUID player) {
CompletableFuture<List<ResourcePackDownloadData>> future = new CompletableFuture<>();
CraftEngine.instance().scheduler().executeAsync(() -> {
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/api/fs/get"))
.header("Authorization", getOrRefreshJwtToken())
.header("Content-Type", "application/json")
.POST(getRequestResourcePackDownloadLinkPost())
.build();
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> handleResourcePackDownloadLinkResponse(response, future))
.exceptionally(ex -> {
CraftEngine.instance().logger().severe("[Alist] Failed to request resource pack download link", ex);
future.completeExceptionally(ex);
return null;
});
}
});
return future;
}
@Override
public CompletableFuture<Void> upload(Path resourcePackPath) {
CompletableFuture<Void> future = new CompletableFuture<>();
if (this.localFilePath != null) resourcePackPath = this.localFilePath;
Path finalResourcePackPath = resourcePackPath;
CraftEngine.instance().scheduler().executeAsync(() -> {
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/api/fs/put"))
.header("Authorization", getOrRefreshJwtToken())
.header("File-Path", URLEncoder.encode(filePath, StandardCharsets.UTF_8)
.replace("/", "%2F"))
.header("overwrite", "true")
.header("password", filePassword)
.header("Content-Type", "application/x-zip-compressed")
.PUT(HttpRequest.BodyPublishers.ofFile(finalResourcePackPath))
.build();
long requestStart = System.currentTimeMillis();
CraftEngine.instance().logger().info("[Alist] Starting file upload...");
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> {
long uploadTime = System.currentTimeMillis() - requestStart;
if (response.statusCode() == 200) {
cacheSha1 = calculateLocalFileSha1(finalResourcePackPath);
saveCacheToDisk();
CraftEngine.instance().logger().info("[Alist] Upload resource pack success after " + uploadTime + "ms");
future.complete(null);
} else {
future.completeExceptionally(new RuntimeException("Upload failed with status code: " + response.statusCode()));
}
})
.exceptionally(ex -> {
long uploadTime = System.currentTimeMillis() - requestStart;
CraftEngine.instance().logger().severe(
"[Alist] Failed to upload resource pack after " + uploadTime + "ms", ex);
future.completeExceptionally(ex);
return null;
});
} catch (IOException e) {
CraftEngine.instance().logger().warn("[Alist] Failed to upload resource pack: " + e.getMessage());
future.completeExceptionally(e);
}
});
return future;
}
@Nullable
private String getOrRefreshJwtToken() {
if (jwtToken == null || jwtToken.right().before(new Date())) {
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/api/auth/login"))
.header("Content-Type", "application/json")
.POST(getLoginPost())
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
CraftEngine.instance().logger().warn("[Alist] Failed to get JWT token: " + response.body());
return null;
}
JsonObject jsonData = parseJson(response.body());
JsonElement code = jsonData.get("code");
if (code.isJsonPrimitive() && code.getAsJsonPrimitive().isNumber() && code.getAsJsonPrimitive().getAsInt() == 200) {
JsonElement data = jsonData.get("data");
if (data.isJsonObject()) {
JsonObject jsonObj = data.getAsJsonObject();
jwtToken = Pair.of(
jsonObj.getAsJsonPrimitive("token").getAsString(),
new Date(System.currentTimeMillis() + jwtTokenExpiration.toMillis())
);
return jwtToken.left();
}
CraftEngine.instance().logger().warn("[Alist] Failed to get JWT token: " + response.body());
return null;
}
CraftEngine.instance().logger().warn("[Alist] Failed to get JWT token: " + response.body());
return null;
} catch (IOException | InterruptedException e) {
CraftEngine.instance().logger().warn("[Alist] Failed to get JWT token", e);
return null;
}
}
return jwtToken.left();
}
private HttpRequest.BodyPublisher getLoginPost() {
String body = "{\"username\":\"" + userName + "\",\"password\":\"" + password + "\"";
if (otpCode != null && !otpCode.isEmpty()) {
body += ",\"otp_code\":\"" + otpCode + "\"";
}
body += "}";
return HttpRequest.BodyPublishers.ofString(body);
}
private HttpRequest.BodyPublisher getRequestResourcePackDownloadLinkPost() {
String body = "{\"path\":\"" + filePath + "\",\"password\":\"" + filePassword + "\"}";
return HttpRequest.BodyPublishers.ofString(body);
}
private void handleResourcePackDownloadLinkResponse(
HttpResponse<String> response, CompletableFuture<List<ResourcePackDownloadData>> future) {
if (response.statusCode() == 200) {
JsonObject json = parseJson(response.body());
JsonElement code = json.get("code");
if (code.isJsonPrimitive() && code.getAsJsonPrimitive().isNumber() && code.getAsJsonPrimitive().getAsInt() == 200) {
JsonElement data = json.get("data");
if (data.isJsonObject()) {
JsonObject dataObj = data.getAsJsonObject();
boolean isDir = dataObj.getAsJsonPrimitive("is_dir").getAsBoolean();
if (!isDir) {
String url = dataObj.getAsJsonPrimitive("raw_url").getAsString();
UUID uuid = UUID.nameUUIDFromBytes(cacheSha1.getBytes(StandardCharsets.UTF_8));
future.complete(List.of(new ResourcePackDownloadData(url, uuid, cacheSha1)));
return;
}
}
}
}
future.completeExceptionally(
new RuntimeException("Failed to request resource pack download link: " + response.body()));
}
private JsonObject parseJson(String json) {
try {
return GsonHelper.get().fromJson(
json,
JsonObject.class
);
} catch (JsonSyntaxException e) {
throw new RuntimeException("Invalid JSON response: " + json, e);
}
}
private String calculateLocalFileSha1(Path filePath) {
try (InputStream is = Files.newInputStream(filePath)) {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] buffer = new byte[8192];
int len;
while ((len = is.read(buffer)) != -1) {
md.update(buffer, 0, len);
}
byte[] digest = md.digest();
return HexFormat.of().formatHex(digest);
} catch (IOException | NoSuchAlgorithmException e) {
throw new RuntimeException("Failed to calculate SHA1", e);
}
}
public static class Factory implements ResourcePackHostFactory {
@Override
public ResourcePackHost create(Map<String, Object> arguments) {
String apiUrl = (String) arguments.get("api-url");
if (apiUrl == null || apiUrl.isEmpty()) {
throw new IllegalArgumentException("'api-url' cannot be empty for Alist host");
}
String userName = (String) arguments.get("username");
if (userName == null || userName.isEmpty()) {
throw new IllegalArgumentException("'username' cannot be empty for Alist host");
}
String password = (String) arguments.get("password");
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("'password' cannot be empty for Alist host");
}
String filePassword = (String) arguments.getOrDefault("file-password", "");
String otpCode = (String) arguments.get("otp-code");
Duration jwtTokenExpiration = Duration.ofHours((int) arguments.getOrDefault("jwt-token-expiration", 48));
String filePath = (String) arguments.get("file-path");
if (filePath == null || filePath.isEmpty()) {
throw new IllegalArgumentException("'file-path' cannot be empty for Alist host");
}
String localFilePath = (String) arguments.get("local-file-path");
return new AlistHost(apiUrl, userName, password, filePassword, otpCode, jwtTokenExpiration, filePath, localFilePath);
}
}
}