XuqmGroup-Server/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java

402 行
16 KiB
Java

package com.xuqm.update.service;
import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.model.RnBundleInspectResult;
import net.dongliu.apk.parser.ApkFile;
import net.dongliu.apk.parser.bean.ApkMeta;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ContentDisposition;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.Map;
import java.util.Locale;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
@Service
public class UpdateAssetService {
private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class);
@Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir;
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl;
public String storeAppPackage(MultipartFile apkFile) throws IOException {
if (apkFile == null || apkFile.isEmpty()) {
return null;
}
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
Path dir = Paths.get(uploadDir, "apk");
Files.createDirectories(dir);
Path dest = dir.resolve(filename);
apkFile.transferTo(dest.toFile());
return baseUrl + "/files/apk/" + filename;
}
public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception {
if (packageUrl == null || packageUrl.isBlank()) {
return new AppPackageInspectResult(null, null, null, null, null, false);
}
try {
RemotePackage remote = downloadRemotePackage(packageUrl, true);
try {
return inspectDownloadedPackage(remote.path(), remote.fileName());
} finally {
Files.deleteIfExists(remote.path());
}
} catch (Exception ex) {
log.warn("Failed to inspect remote package {}, fallback to undetected result: {}", packageUrl, ex.getMessage());
return fallbackInspectResult(packageUrl);
}
}
public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception {
String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null)
.orElse("");
if (packageFile == null || packageFile.isEmpty()) {
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
}
Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName));
try {
try (InputStream in = packageFile.getInputStream()) {
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
}
return inspectDownloadedPackage(temp, fileName);
} catch (Exception ex) {
log.warn("Failed to inspect uploaded package {}, fallback to undetected result: {}", fileName, ex.getMessage());
return fallbackInspectResult(fileName);
} finally {
Files.deleteIfExists(temp);
}
}
public Path downloadRemotePackageToCache(String packageUrl) throws IOException {
return downloadRemotePackage(packageUrl, false).path();
}
public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception {
String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null)
.orElse("");
if (bundle == null || bundle.isEmpty()) {
return new RnBundleInspectResult(null, null, null, null, null, fileName, false);
}
return inspectRnBundleName(fileName);
}
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
if (bundle == null || bundle.isEmpty()) {
throw new IllegalArgumentException("bundle file is required");
}
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
Files.createDirectories(dir);
Path dest = dir.resolve(filename);
String md5 = computeMd5(bundle);
bundle.transferTo(dest.toFile());
return new StoredRnBundle(dest.toAbsolutePath().toString(), md5);
}
private String computeMd5(MultipartFile file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("MD5");
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
byte[] buf = new byte[8192];
while (dis.read(buf) != -1) {
// read fully
}
}
return HexFormat.of().formatHex(digest.digest());
}
private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception {
try (ApkFile apk = new ApkFile(file.toFile())) {
ApkMeta meta = apk.getApkMeta();
Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue();
return new AppPackageInspectResult(
"ANDROID",
blankToNull(meta.getPackageName()),
blankToNull(meta.getVersionName()),
versionCode,
fileName,
true);
}
}
private AppPackageInspectResult inspectDownloadedPackage(Path file, String fileName) throws Exception {
String normalized = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
if (normalized.endsWith(".apk")) {
return inspectApk(file, fileName);
}
if (normalized.endsWith(".ipa")) {
return inspectIpa(file, fileName);
}
return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false);
}
private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception {
try (ZipFile zipFile = new ZipFile(file.toFile())) {
ZipEntry entry = zipFile.stream()
.filter(e -> e.getName().endsWith("Info.plist"))
.findFirst()
.orElse(null);
if (entry != null) {
byte[] data = zipFile.getInputStream(entry).readAllBytes();
String text = new String(data, StandardCharsets.UTF_8);
if (text.contains("<plist")) {
var plist = parsePlistXml(text);
return new AppPackageInspectResult(
"IOS",
blankToNull(plist.get("CFBundleIdentifier")),
blankToNull(plist.get("CFBundleShortVersionString")),
parseInteger(plist.get("CFBundleVersion")),
fileName,
true);
}
}
}
return new AppPackageInspectResult("IOS", null, null, null, fileName, false);
}
private RnBundleInspectResult inspectRnBundleName(String fileName) {
String baseName = stripExtension(fileName);
String[] parts = baseName.split("__");
if (parts.length >= 4) {
return new RnBundleInspectResult(
blankToNull(parts[0]),
platformFromToken(parts[1]),
blankToNull(parts[2]),
blankToNull(parts[3]),
parts.length >= 5 ? blankToNull(parts[4]) : null,
fileName,
true);
}
return new RnBundleInspectResult(
null,
platformFromFileName(fileName),
null,
null,
null,
fileName,
false);
}
private Map<String, String> parsePlistXml(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
factory.setExpandEntityReferences(false);
Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
NodeList dictNodes = document.getElementsByTagName("dict");
if (dictNodes.getLength() == 0) {
return Map.of();
}
Element dict = (Element) dictNodes.item(0);
Map<String, String> values = new java.util.LinkedHashMap<>();
Node child = dict.getFirstChild();
String currentKey = null;
while (child != null) {
if (child.getNodeType() == Node.ELEMENT_NODE) {
String nodeName = child.getNodeName();
if ("key".equals(nodeName)) {
currentKey = child.getTextContent();
} else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) {
values.put(currentKey, child.getTextContent());
currentKey = null;
}
}
child = child.getNextSibling();
}
return values;
}
private String platformFromFileName(String fileName) {
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
if (lower.contains("ios") || lower.endsWith(".ipa")) {
return "IOS";
}
return "ANDROID";
}
private String platformFromToken(String token) {
if (token == null) {
return null;
}
String normalized = token.trim().toUpperCase(Locale.ROOT);
return switch (normalized) {
case "ANDROID", "IOS" -> normalized;
case "A", "ANDROIDSDK" -> "ANDROID";
case "I", "IOSSDK" -> "IOS";
default -> normalized.isBlank() ? null : normalized;
};
}
private String stripExtension(String fileName) {
if (fileName == null) return "";
int idx = fileName.lastIndexOf('.');
return idx > 0 ? fileName.substring(0, idx) : fileName;
}
private String suffixFor(String fileName) {
if (fileName == null) return ".tmp";
int idx = fileName.lastIndexOf('.');
return idx > 0 ? fileName.substring(idx) : ".tmp";
}
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(30_000);
connection.setInstanceFollowRedirects(true);
int status = connection.getResponseCode();
if (status >= 400) {
throw new IOException("Failed to download package: HTTP " + status);
}
String contentType = Optional.ofNullable(connection.getContentType()).orElse("");
String fileName = resolveRemoteFileName(connection, packageUrl, contentType);
Path path = tempFile
? Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName))
: buildRemoteCachePath(packageUrl, fileName, contentType);
if (!tempFile && Files.exists(path)) {
return new RemotePackage(path, fileName, contentType);
}
try (InputStream in = connection.getInputStream()) {
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
}
return new RemotePackage(path, fileName, contentType);
}
private Path buildRemoteCachePath(String packageUrl, String fileName, String contentType) throws IOException {
Path dir = Paths.get(uploadDir, "apk", "remote");
Files.createDirectories(dir);
String safeName = sanitizeFileName(fileName);
String cacheName = sha256Hex(packageUrl) + "_" + safeName;
if (!cacheName.contains(".") && hasText(contentType)) {
cacheName = cacheName + suffixByContentType(contentType);
}
return dir.resolve(cacheName);
}
private String resolveRemoteFileName(HttpURLConnection connection, String packageUrl, String contentType) {
String fileName = null;
String disposition = connection.getHeaderField("Content-Disposition");
if (hasText(disposition)) {
try {
fileName = ContentDisposition.parse(disposition).getFilename();
} catch (Exception ignored) {
fileName = null;
}
if (!hasText(fileName)) {
int idx = disposition.toLowerCase(Locale.ROOT).indexOf("filename=");
if (idx >= 0) {
fileName = disposition.substring(idx + 9).replace("\"", "").trim();
}
}
}
if (!hasText(fileName)) {
String pathName = Paths.get(URI.create(packageUrl).getPath()).getFileName() != null
? Paths.get(URI.create(packageUrl).getPath()).getFileName().toString()
: null;
fileName = hasText(pathName) ? pathName : "downloaded";
}
if (!fileName.contains(".")) {
fileName = fileName + suffixByContentType(contentType);
}
return fileName;
}
private String suffixByContentType(String contentType) {
String lower = Optional.ofNullable(contentType).orElse("").toLowerCase(Locale.ROOT);
if (lower.contains("android.package-archive")) return ".apk";
if (lower.contains("ipa")) return ".ipa";
if (lower.contains("zip")) return ".zip";
return ".bin";
}
private String sanitizeFileName(String fileName) {
String safe = Optional.ofNullable(fileName).orElse("downloaded");
return safe.replaceAll("[\\\\/:*?\"<>|]", "_");
}
private String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
private Integer parseInteger(String value) {
if (value == null || value.isBlank()) return null;
try {
return Integer.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private AppPackageInspectResult fallbackInspectResult(String source) {
String fileName = source;
try {
if (source != null && (source.startsWith("http://") || source.startsWith("https://"))) {
fileName = Optional.ofNullable(Paths.get(URI.create(source).getPath()).getFileName())
.map(Path::toString)
.orElse(source);
}
} catch (Exception ignored) {
fileName = source;
}
return new AppPackageInspectResult(
platformFromFileName(fileName),
null,
null,
null,
fileName,
false);
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
public record StoredRnBundle(String bundlePath, String md5) {}
private record RemotePackage(Path path, String fileName, String contentType) {}
}