From d0b7a7c5d49a6df363b10ca70a4a8724d037eef4 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:59:37 +0800 Subject: [PATCH] fix(deploy): backport sub-path runtime fixes to PR 576 Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .env.release.example | 4 +- README.md | 13 +++++ README_zh.md | 9 +++ scripts/runtime.sh | 57 +++++++++++++++++++ scripts/tests/runtime-secret-test.sh | 14 +++++ .../skillhub/compat/WellKnownController.java | 9 +-- .../skillhub/filter/AuthContextFilter.java | 10 +++- .../filter/AuthContextFilterTest.java | 28 +++++++++ .../skillhub/auth/config/SecurityConfig.java | 9 +-- .../auth/oauth/OAuthLoginRedirectSupport.java | 29 +++++++++- .../auth/oauth/OAuth2LoginHandlersTest.java | 9 +++ 11 files changed, 175 insertions(+), 16 deletions(-) diff --git a/.env.release.example b/.env.release.example index 20c3d438..a7751882 100644 --- a/.env.release.example +++ b/.env.release.example @@ -128,7 +128,9 @@ SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER_URI= # To enable, set BOTH: # - SKILLHUB_AUTH_DIRECT_ENABLED=true (server: enables the /api/v1/auth/direct endpoint) # - SKILLHUB_WEB_AUTH_DIRECT_ENABLED=true (web: surfaces the username/password form) -# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to the provider id (e.g. "local"). +# Set SKILLHUB_WEB_AUTH_DIRECT_PROVIDER to a direct provider id returned by +# /api/v1/auth/methods (e.g. "local"). Do not use the built-in auth method id +# "local-password" here; that method points at /api/v1/auth/local/login. SKILLHUB_AUTH_DIRECT_ENABLED=false SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false SKILLHUB_WEB_AUTH_DIRECT_PROVIDER= diff --git a/README.md b/README.md index 34555dda..9dbe9264 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,9 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u # Aliyun mirror (recommended for users in China) curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest + +# Sub-path deployment behind a reverse proxy +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com/skillhub --base-path /skillhub/ ``` **Deployment parameters:** @@ -253,6 +256,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u | Parameter | Description | Example | |-----------|-------------|---------| | `--public-url ` | Public access URL (recommended) | `--public-url https://skill.example.com` | +| `--base-path ` | Serve the Web UI under a reverse-proxy sub-path. Also sets same-origin API routing for that path. | `--base-path /skillhub/` | | `--version ` | Specific image tag | `--version v0.2.0` | | `--aliyun` | Use Aliyun mirror (China) | `--aliyun` | | `--home ` | Runtime directory | `--home /opt/skillhub` | @@ -260,6 +264,12 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u > **Important**: Configure `--public-url` for production deployments to ensure CLI install commands and Agent setup instructions display the correct URLs. +For sub-path deployments, keep the public URL and base path aligned. For example, +`--public-url https://skill.example.com/skillhub --base-path /skillhub/` writes +`SKILLHUB_PUBLIC_BASE_URL=https://skill.example.com/skillhub`, +`SKILLHUB_WEB_BASE_PATH=/skillhub/`, and `SKILLHUB_WEB_API_BASE_URL=/skillhub` +into the runtime environment. + **Manual deployment:** 1. Copy the runtime environment template. @@ -308,6 +318,9 @@ enables the bootstrap admin by default, so zero-config quickstart via Recommended production baseline: - set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint +- if the service is published under a sub-path such as `/skillhub/`, set + `SKILLHUB_WEB_BASE_PATH=/skillhub/` and `SKILLHUB_WEB_API_BASE_URL=/skillhub` + as well, or use `runtime.sh --base-path /skillhub/` - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` - change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`) diff --git a/README_zh.md b/README_zh.md index c8132525..e7eb5787 100644 --- a/README_zh.md +++ b/README_zh.md @@ -235,6 +235,9 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u # 阿里云镜像(国内推荐) curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest + +# 通过反向代理部署到子路径 +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com/skillhub --base-path /skillhub/ ``` ### 配置参数说明 @@ -242,6 +245,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u | 参数 | 说明 | 示例 | |------|------|------| | `--public-url ` | 公网访问地址(推荐配置) | `--public-url https://skill.example.com` | +| `--base-path ` | 将 Web UI 发布到反向代理子路径,并同步配置同源 API 路由 | `--base-path /skillhub/` | | `--version ` | 指定镜像版本 | `--version v0.2.0` | | `--aliyun` | 使用阿里云镜像(国内推荐) | `--aliyun` | | `--home ` | 指定运行时目录 | `--home /opt/skillhub` | @@ -249,6 +253,11 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u > **重要**:生产环境请务必配置 `--public-url`,确保 CLI 安装命令和 Agent 设置指引显示正确的地址。 +如果通过 `/skillhub/` 这类子路径对外发布,需要让公网地址和前端基础路径保持一致。 +例如 `--public-url https://skill.example.com/skillhub --base-path /skillhub/` +会写入 `SKILLHUB_PUBLIC_BASE_URL=https://skill.example.com/skillhub`、 +`SKILLHUB_WEB_BASE_PATH=/skillhub/` 和 `SKILLHUB_WEB_API_BASE_URL=/skillhub`。 + ### 使用 Kubernetes ```bash diff --git a/scripts/runtime.sh b/scripts/runtime.sh index fec6375e..c8b3fcdb 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -13,6 +13,7 @@ SKILLHUB_HOME_DEFAULT="${TMPDIR:-/tmp}/skillhub-runtime" SKILLHUB_HOME="${SKILLHUB_HOME:-$SKILLHUB_HOME_DEFAULT}" SKILLHUB_VERSION_VALUE="${SKILLHUB_VERSION:-}" SKILLHUB_PUBLIC_BASE_URL_VALUE="${SKILLHUB_PUBLIC_BASE_URL:-}" +SKILLHUB_WEB_BASE_PATH_VALUE="${SKILLHUB_WEB_BASE_PATH:-}" SKILLHUB_ALIYUN_REGISTRY="${SKILLHUB_ALIYUN_REGISTRY:-crpi-ptu2rqimrigtq0qx.cn-hangzhou.personal.cr.aliyuncs.com}" SKILLHUB_ALIYUN_NAMESPACE="${SKILLHUB_ALIYUN_NAMESPACE:-skill_hub}" SKILLHUB_MIRROR_REGISTRY_VALUE="${SKILLHUB_MIRROR_REGISTRY:-}" @@ -89,6 +90,11 @@ while [ "$#" -gt 0 ]; do SKILLHUB_PUBLIC_BASE_URL_VALUE="$2" shift 2 ;; + --base-path) + [ "$#" -ge 2 ] || { echo "Missing value for --base-path" >&2; exit 1; } + SKILLHUB_WEB_BASE_PATH_VALUE="$2" + shift 2 + ;; --help|-h) cat < Store runtime files in a specific directory --ref Download runtime files from a specific Git ref --public-url Public access URL (e.g. https://skill.example.com) + --base-path Serve Web UI under a sub-path, e.g. /skillhub/ --server-image Override backend image repository --web-image Override frontend image repository --scanner-image Override scanner image repository @@ -183,6 +190,46 @@ set_env_value() { secure_env_file } +normalize_base_path() { + value="$1" + [ -n "$value" ] || { echo "--base-path must not be empty" >&2; exit 1; } + + case "$value" in + /) + printf '/' + return 0 + ;; + /*/) ;; + /*) value="$value/" ;; + *) value="/$value/" ;; + esac + + case "$value" in + *//*|*[!A-Za-z0-9._~/-]*) + echo "--base-path contains unsupported characters: $value" >&2 + exit 1 + ;; + esac + + case "$value" in + */./*|*/../*) + echo "--base-path must not contain '.' or '..' path segments: $value" >&2 + exit 1 + ;; + esac + + first_segment=${value#/} + first_segment=${first_segment%%/*} + case "$first_segment" in + api|oauth2|login|assets|registry|nginx-health|.well-known|runtime-config.js) + echo "--base-path must not start with a reserved SkillHub path segment: $first_segment" >&2 + exit 1 + ;; + esac + + printf '%s' "$value" +} + secure_env_file() { if [ -f "$ENV_FILE" ]; then chmod 600 "$ENV_FILE" @@ -359,6 +406,16 @@ prepare_runtime_files() { set_env_value "SKILLHUB_PUBLIC_BASE_URL" "$SKILLHUB_PUBLIC_BASE_URL_VALUE" fi + if [ -n "$SKILLHUB_WEB_BASE_PATH_VALUE" ]; then + normalized_base_path="$(normalize_base_path "$SKILLHUB_WEB_BASE_PATH_VALUE")" + set_env_value "SKILLHUB_WEB_BASE_PATH" "$normalized_base_path" + if [ "$normalized_base_path" = "/" ]; then + set_env_value "SKILLHUB_WEB_API_BASE_URL" "" + else + set_env_value "SKILLHUB_WEB_API_BASE_URL" "${normalized_base_path%/}" + fi + fi + if [ "$DISABLE_SCANNER" = "true" ]; then set_env_value "SKILLHUB_SECURITY_SCANNER_ENABLED" "false" fi diff --git a/scripts/tests/runtime-secret-test.sh b/scripts/tests/runtime-secret-test.sh index 506b58fd..81d56a86 100755 --- a/scripts/tests/runtime-secret-test.sh +++ b/scripts/tests/runtime-secret-test.sh @@ -120,4 +120,18 @@ grep -Fq "SKILLHUB_SECURITY_SCANNER_ENABLED=false" "$home_no_scanner/.env.releas grep -Fq -- "up -d --no-deps --scale skill-scanner=0 server web" "$home_no_scanner/docker.log" \ || fail "runtime --no-scanner should start server/web without waiting on scanner dependencies" +home_sub_path="$tmp/sub-path" +stdout_sub_path="$tmp/sub-path.out" +mkdir -p "$home_sub_path" +run_runtime "$home_sub_path" "$bin_dir" "$stdout_sub_path" \ + --public-url http://localhost/skillhub \ + --base-path skillhub + +grep -Fq "SKILLHUB_PUBLIC_BASE_URL=http://localhost/skillhub" "$home_sub_path/.env.release" \ + || fail "runtime should persist the public URL for sub-path deployments" +grep -Fq "SKILLHUB_WEB_BASE_PATH=/skillhub/" "$home_sub_path/.env.release" \ + || fail "runtime should normalize and persist SKILLHUB_WEB_BASE_PATH" +grep -Fq "SKILLHUB_WEB_API_BASE_URL=/skillhub" "$home_sub_path/.env.release" \ + || fail "runtime should align SKILLHUB_WEB_API_BASE_URL with the base path" + echo "runtime-secret-test passed" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java index 263634aa..c131d84b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/WellKnownController.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.compat; +import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -11,14 +12,8 @@ import java.util.Map; */ @RestController public class WellKnownController { - @GetMapping("/.well-known/clawhub.json") public Map clawhubConfig(HttpServletRequest request) { - // Honor the deployment sub-path so CLI clients discover //api/v1 instead of - // the domain-root /api/v1. With forward-headers-strategy=framework, an upstream - // X-Forwarded-Prefix is reflected into the request context path. - String contextPath = request.getContextPath(); - String prefix = (contextPath == null) ? "" : contextPath; - return Map.of("apiBase", prefix + "/api/v1"); + return Map.of("apiBase", OAuthLoginRedirectSupport.apiBase(request)); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 1c16fe89..a53ee303 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -59,7 +59,7 @@ public class AuthContextFilter extends OncePerRequestFilter { HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(request.getRequestURI())) { + if (!routeSecurityPolicyRegistry.shouldProjectRequestContext(requestPath(request))) { filterChain.doFilter(request, response); return; } @@ -88,6 +88,14 @@ public class AuthContextFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + private String requestPath(HttpServletRequest request) { + String servletPath = request.getServletPath(); + if (servletPath != null && !servletPath.isBlank()) { + return servletPath; + } + return request.getRequestURI(); + } + private boolean isInactiveUser(String userId) { if (!enforceActiveUserCheck) { return false; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index f3d2d117..c796bab6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -120,6 +120,34 @@ class AuthContextFilterTest { verify(filterChain).doFilter(request, response); } + @Test + void forwardedPrefixApiRequest_shouldUseServletPathForContextProjection() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-3", "Cara", "cara@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("user-3", "Cara", "cara@example.com", null); + user.setStatus(UserStatus.ACTIVE); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("/skillhub"); + request.setRequestURI("/skillhub/api/web/me/namespaces"); + request.setServletPath("/api/web/me/namespaces"); + request.getSession(true).setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-3")).thenReturn(java.util.Optional.of(user)); + when(namespaceMemberRepository.findByUserId("user-3")).thenReturn(List.of()); + + filter.doFilter(request, response, filterChain); + + assertEquals("user-3", request.getAttribute("userId")); + assertTrue(((java.util.Map) request.getAttribute("userNsRoles")).isEmpty()); + verify(filterChain).doFilter(request, response); + } + @Test void anonymousRequest_shouldPassThroughWithoutLoadingUserContext() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 7f5154a4..354c1502 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; import com.iflytek.skillhub.auth.oauth.CustomOidcUserService; import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler; import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport; import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver; import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; @@ -152,12 +153,8 @@ public class SecurityConfig { ) .logout(logout -> logout .logoutUrl("/api/v1/auth/logout") - // Redirect to the deployment root honoring any sub-path prefix (X-Forwarded-Prefix - // is reflected into the context path), so logout does not escape a sub-path deployment. - .logoutSuccessHandler((request, response, authentication) -> { - String contextPath = request.getContextPath(); - response.sendRedirect(((contextPath == null) ? "" : contextPath) + "/"); - }) + .logoutSuccessHandler((request, response, authentication) -> + response.sendRedirect(OAuthLoginRedirectSupport.webRoot(request))) .invalidateHttpSession(true) .deleteCookies("SESSION") ) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java index feb60816..c8e369f5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -1,7 +1,10 @@ package com.iflytek.skillhub.auth.oauth; +import jakarta.servlet.http.HttpServletRequest; + /** - * Utility methods and constants for safely handling post-login redirect targets in OAuth flows. + * Utility methods and constants for safely handling OAuth redirect targets and + * deployment-path aware browser-visible URLs. */ public final class OAuthLoginRedirectSupport { @@ -11,6 +14,30 @@ public final class OAuthLoginRedirectSupport { private OAuthLoginRedirectSupport() { } + public static String apiBase(HttpServletRequest request) { + return deploymentPrefix(request) + "/api/v1"; + } + + public static String webRoot(HttpServletRequest request) { + return deploymentPrefix(request) + "/"; + } + + static String deploymentPrefix(HttpServletRequest request) { + if (request == null) { + return ""; + } + String rawPrefix = request.getContextPath(); + if (rawPrefix == null || rawPrefix.isBlank() || "/".equals(rawPrefix)) { + return ""; + } + String prefix = rawPrefix.endsWith("/") ? rawPrefix.substring(0, rawPrefix.length() - 1) : rawPrefix; + if (!prefix.startsWith("/") || prefix.contains("//") || prefix.contains("\\") + || prefix.contains("?") || prefix.contains("#")) { + return ""; + } + return prefix; + } + public static String sanitizeReturnTo(String candidate) { if (candidate == null || candidate.isBlank()) { return null; diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index de3a8411..2e46908f 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -108,6 +108,15 @@ class OAuth2LoginHandlersTest { assertThat(response.getRedirectedUrl()).isEqualTo("/skillhub/dashboard/publish"); } + @Test + void deploymentPathSupport_returnsPrefixedApiAndWebRoot() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContextPath("/skillhub"); + + assertThat(OAuthLoginRedirectSupport.apiBase(request)).isEqualTo("/skillhub/api/v1"); + assertThat(OAuthLoginRedirectSupport.webRoot(request)).isEqualTo("/skillhub/"); + } + /** * Regression test: when an unauthenticated client hits a protected API endpoint, Spring Security * caches that request. With {@code SavedRequestAwareAuthenticationSuccessHandler} the post-login