fix: normalize Gitea release asset URLs
Release / release (push) Failing after 8s

This commit is contained in:
Qiufeng
2026-08-17 16:42:03 +08:00
parent 7394c9e7e4
commit f9912ade7e
8 changed files with 109 additions and 62 deletions
@@ -15,6 +15,7 @@ import com.kaidi.finance.update.api.SystemUpdateView;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -52,6 +53,9 @@ public class SystemUpdateApplicationService {
+ "(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$");
private static final Pattern ARTIFACT = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$");
private static final Pattern SHA256 = Pattern.compile("^[0-9a-fA-F]{64}$");
private static final Pattern GITEA_RELEASE_API_PATH = Pattern.compile(
"^(.*/)?api/v1/repos/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/releases/(?:latest|tags/[^/?#]+)$");
private static final Pattern GITEA_RELEASE_TAG = Pattern.compile("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$");
private static final List<String> BUSY_STATES = List.of(
"QUEUED", "DOWNLOAD_QUEUED", "INSTALL_QUEUED", "VERIFYING", "DOWNLOADING", "BACKING_UP",
"INSTALLING", "RUNNING");
@@ -195,20 +199,42 @@ public class SystemUpdateApplicationService {
URI api = releaseApiUri();
try {
JsonNode release = objectMapper.readTree(fetchReleaseBytes(api, MAX_RELEASE_API_BYTES, "Gitea Release"));
boolean assetPresent = false;
for (JsonNode asset : release.path("assets")) {
if (!assetName.equals(asset.path("name").asText())) continue;
URI uri = validatedReleaseUri(asset.path("browser_download_url").asText(null), "Release 资源地址");
if (!sameOrigin(api, uri)) {
throw releaseUnavailable("Gitea Release 资源必须与 API 使用同一来源");
if (assetName.equals(asset.path("name").asText())) {
assetPresent = true;
break;
}
return uri;
}
throw releaseUnavailable("Gitea Release 缺少 " + assetName);
if (!assetPresent) throw releaseUnavailable("Gitea Release 缺少 " + assetName);
String tag = release.path("tag_name").asText(null);
if (tag == null || !GITEA_RELEASE_TAG.matcher(tag).matches()) {
throw releaseUnavailable("Gitea Release tag 格式无效");
}
return trustedGiteaAssetUri(api, tag, assetName);
} catch (IOException exception) {
throw releaseUnavailable("Gitea Release 响应读取失败");
}
}
private URI trustedGiteaAssetUri(URI api, String tag, String assetName) {
if (api.getRawQuery() != null || api.getRawFragment() != null) {
throw releaseUnavailable("Gitea Release API 地址格式无效");
}
Matcher matcher = GITEA_RELEASE_API_PATH.matcher(api.getPath());
if (!matcher.matches()) {
throw releaseUnavailable("Gitea Release API 地址必须指向仓库 Release 端点");
}
String prefix = matcher.group(1) == null ? "/" : matcher.group(1);
String path = prefix + matcher.group(2) + "/" + matcher.group(3)
+ "/releases/download/" + tag + "/" + assetName;
try {
return new URI(api.getScheme(), null, api.getHost(), api.getPort(), path, null, null);
} catch (URISyntaxException exception) {
throw releaseUnavailable("Gitea Release 资源地址格式无效");
}
}
private byte[] fetchReleaseBytes(URI uri, int maximumBytes, String resourceName) {
String token = releaseToken();
URI current = uri;
@@ -47,18 +47,17 @@ class SystemUpdateApplicationServiceTest {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/api/v1/repos/ERP-Team/kaidi/releases/latest", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
String assetUrl = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/assets/release-manifest.json";
byte[] body = ("""
{"tag_name":"v1.0.0-preview.2","assets":[
{"name":"release-manifest.json","browser_download_url":"%s"}
]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
""").formatted("http://10.0.0.8:3000/internal/release-manifest.json")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.createContext("/assets/release-manifest.json", exchange -> {
server.createContext("/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
@@ -98,7 +97,7 @@ class SystemUpdateApplicationServiceTest {
}
@Test
void rejectsPrivateGiteaAssetOnAnotherOriginWithoutSendingToken() throws Exception {
void ignoresCrossOriginBrowserAssetUrlWithoutSendingTokenThere() throws Exception {
AtomicInteger assetRequests = new AtomicInteger();
HttpServer assetServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
assetServer.createContext("/release-manifest.json", exchange -> {
@@ -112,12 +111,25 @@ class SystemUpdateApplicationServiceTest {
String assetUrl = "http://127.0.0.1:" + assetServer.getAddress().getPort()
+ "/release-manifest.json";
byte[] body = ("""
{"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
{"tag_name":"v1.0.0-preview.2",
"assets":[{"name":"release-manifest.json","browser_download_url":"%s"}]}
""").formatted(assetUrl).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
apiServer.createContext(
"/ERP-Team/kaidi/releases/download/v1.0.0-preview.2/release-manifest.json", exchange -> {
assertEquals("token read-only-token", exchange.getRequestHeaders().getFirst("Authorization"));
byte[] body = """
{"version":"1.0.0-preview.2","artifact":"kaidi-finance-1.0.0-preview.2.tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"publishedAt":"2026-08-16T00:00:00Z","releaseNotes":"Gitea Preview update"}
""".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
assetServer.start();
apiServer.start();
try {
@@ -125,7 +137,7 @@ class SystemUpdateApplicationServiceTest {
SystemUpdateApplicationService service = serviceForGitea(apiServer, inbox,
tempDir.resolve("cross-origin-status.json"));
assertThrows(BusinessException.class, service::check);
assertEquals("1.0.0-preview.2", service.check().latestVersion());
assertEquals(0, assetRequests.get());
} finally {
apiServer.stop(0);