From 9054fb6ee2531a5688ecb4d9cadaaa37f90846cd Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:18:36 +0800 Subject: [PATCH] fix(compat): percent-encode ClawHub download redirect Location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloading a skill whose slug is non-ASCII (e.g. a Chinese name) through the ClawHub CLI compatibility route failed: the 302 Location header was built by string-concatenating the raw slug, and Tomcat encodes header values as ISO-8859-1, so a character outside 0-255 makes it drop the Location header entirely and the download breaks. The skillhub CLI path was unaffected because it doesn't go through this redirect. Build the Location with UriComponentsBuilder.pathSegment(...).encode(), so each segment is percent-encoded while the '/' separators stay literal. "需求" becomes %E9%9C%80%E6%B1%82 and the header is ISO-8859-1-writable. Fixes #658 Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> (cherry picked from commit 34dc4fa29c6a98bff1ad16ecc2a118d154d358d3) --- .../compat/ClawHubCompatAppService.java | 30 +++++++++++--- .../compat/ClawHubCompatAppServiceTest.java | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f..74f96dde 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -30,6 +30,7 @@ import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; /** * Compatibility-focused application service that keeps ClawHub transport logic @@ -131,9 +132,7 @@ public class ClawHubCompatAppService { public String downloadLocationByPath(String canonicalSlug, String version) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - return "latest".equals(version) - ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" - : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return buildDownloadLocation(coord, version); } public String downloadLocationByQuery(String slug, @@ -141,9 +140,28 @@ public class ClawHubCompatAppService { String userId, Map userNsRoles) { SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); - return "latest".equals(version) - ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" - : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return buildDownloadLocation(coord, version); + } + + /** + * Builds the redirect Location for a download. + * + *

+ * Each path segment is percent-encoded, because a non-ASCII slug (for example a + * Chinese skill name) cannot be written into the HTTP {@code Location} header as-is: + * Tomcat encodes header values as ISO-8859-1 and drops the header when a character + * falls outside 0-255, which breaks the ClawHub CLI download. Using + * {@code pathSegment(...)} keeps the '/' separators literal while encoding the + * segment contents. + */ + private String buildDownloadLocation(SkillCoordinate coord, String version) { + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/v1/skills") + .pathSegment(coord.namespace(), coord.slug()); + if (!"latest".equals(version)) { + builder.pathSegment("versions", version); + } + builder.pathSegment("download"); + return builder.encode().toUriString(); } private SkillCoordinate resolveQueryCoordinate(String slug, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index c5921f59..2670031c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -77,4 +77,45 @@ class ClawHubCompatAppServiceTest { assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); } + + @Test + void downloadLocationByQuery_percentEncodesNonAsciiSlug() { + // A non-ASCII slug (e.g. a Chinese skill name) must be percent-encoded, otherwise + // Tomcat drops the Location header (ISO-8859-1 only) and the ClawHub CLI download fails. + Namespace namespace = new Namespace("global", "Global", "owner-1"); + Skill cjkSkill = new Skill(1L, "需求", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + cjkSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("需求")).thenReturn(context); + when(compatSkillLookupService.canAccess(cjkSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("需求", "20260707.025847", null, null); + + assertThat(location) + .isEqualTo("/api/v1/skills/global/%E9%9C%80%E6%B1%82/versions/20260707.025847/download"); + // The header value must be writable as ISO-8859-1 (all bytes in 0-255). + assertThat(java.nio.charset.StandardCharsets.ISO_8859_1.newEncoder().canEncode(location)).isTrue(); + } + + @Test + void downloadLocationByQuery_includesVersionSegmentForAsciiSlug() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + publicSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context); + when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("my-skill", "20260707.025847", null, null); + + assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download"); + } }