From eded92488aac87029173ea0ddd0351c69570ff2e Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 17:34:08 +0800 Subject: [PATCH 01/14] fix: keep download counts consistent across skill pages --- .../skill/service/SkillDownloadService.java | 2 +- .../service/SkillDownloadServiceTest.java | 3 +++ .../event/DownloadCountEventListener.java | 23 ------------------- 3 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 83c5dc8e..09bd835b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -153,7 +153,7 @@ private DownloadResult downloadVersion(Skill skill, SkillVersion version) { result = buildBundleFromFiles(skill, version); } - // Publish download event + skillRepository.incrementDownloadCount(skill.getId()); eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId())); return result; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java index a2c11666..67f81f12 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java @@ -107,6 +107,7 @@ void testDownloadLatest_Success() throws Exception { assertEquals("test-skill-1.0.0.zip", result.filename()); assertEquals(1000L, result.contentLength()); assertNotNull(result.content()); + verify(skillRepository).incrementDownloadCount(1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -149,6 +150,7 @@ void testDownloadByTag_Success() throws Exception { assertNotNull(result); assertEquals("test-skill-1.0.0.zip", result.filename()); assertNotNull(result.content()); + verify(skillRepository).incrementDownloadCount(1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } @@ -256,6 +258,7 @@ void testDownloadVersion_ShouldFallbackToBundledFilesWhenBundleIsMissing() throw assertEquals("test", output.toString()); } + verify(skillRepository).incrementDownloadCount(1L); verify(eventPublisher).publishEvent(any(SkillDownloadedEvent.class)); } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java deleted file mode 100644 index 882b9d46..00000000 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.iflytek.skillhub.search.event; - -import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; -import com.iflytek.skillhub.domain.skill.SkillRepository; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; - -@Component -public class DownloadCountEventListener { - - private final SkillRepository skillRepository; - - public DownloadCountEventListener(SkillRepository skillRepository) { - this.skillRepository = skillRepository; - } - - @EventListener - @Async("skillhubEventExecutor") - public void onSkillDownloaded(SkillDownloadedEvent event) { - skillRepository.incrementDownloadCount(event.skillId()); - } -} From 5e89a4db6b9946a33e20ab5edc6c330824ce4e95 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 17:36:04 +0800 Subject: [PATCH 02/14] fix: stabilize empty search ordering across sorts --- .../PostgresFullTextQueryService.java | 16 +++++---- .../PostgresFullTextQueryServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index d6fe80b0..158bcb25 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -122,11 +122,13 @@ public SearchResult search(SearchQuery query) { // Sorting if ("downloads".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC "); + sql.append("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, "); + sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); } else if ("rating".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); + sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC, "); + sql.append("(SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); } else if ("newest".equals(query.sortBy())) { - sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); + sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC "); } else if (useRelevanceOrdering) { sql.append("ORDER BY CASE "); sql.append("WHEN ").append(TITLE_SQL).append(" = :titleExact THEN 4 "); @@ -135,14 +137,14 @@ public SearchResult search(SearchQuery query) { sql.append("ELSE 1 END DESC, "); if (useShortPrefixTitleSearch) { sql.append("ts_rank_cd(").append(TITLE_VECTOR_SQL) - .append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); + .append(", to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC "); } else if (hasTsQuery) { - sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC "); + sql.append("ts_rank_cd(search_vector, to_tsquery('simple', :tsQuery)) DESC, updated_at DESC, skill_id DESC "); } else { - sql.append("updated_at DESC "); + sql.append("updated_at DESC, skill_id DESC "); } } else { - sql.append("ORDER BY updated_at DESC "); + sql.append("ORDER BY updated_at DESC, skill_id DESC "); } // Pagination diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index 23bdc798..5ca138e6 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -229,6 +229,41 @@ void downloadsSortShouldNotBindRelevanceOnlyParameters() { verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titleExact"), anyString()); verify(nativeQuery, never()).setParameter(org.mockito.ArgumentMatchers.eq("titlePrefix"), anyString()); verify(nativeQuery).setParameter("titleLike", "%51222222333%"); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("ORDER BY (SELECT download_count FROM skill WHERE id = skill_id) DESC, (SELECT updated_at FROM skill WHERE id = skill_id) DESC, skill_id DESC"); + } + + @Test + void emptyKeywordRelevanceShouldUseStableNewestOrdering() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + null, + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 12 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()) + .contains("ORDER BY updated_at DESC, skill_id DESC"); } @Test From 55586ef11bc8f2505a7fc38fc76ac1701ac7386f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 19:32:38 +0800 Subject: [PATCH 03/14] fix: show disabled-account reason on login redirect --- .../skillhub/filter/AuthContextFilter.java | 16 +++++++++++- .../filter/AuthContextFilterTest.java | 25 ++++++++++++++++--- web/src/app/router.tsx | 3 ++- web/src/pages/login.tsx | 6 +++++ web/src/shared/lib/api-error.test.ts | 9 +++++++ web/src/shared/lib/api-error.ts | 25 +++++++++++++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) 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 39352491..0338be30 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 @@ -1,10 +1,12 @@ package com.iflytek.skillhub.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -14,6 +16,7 @@ import java.util.Map; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.context.HttpSessionSecurityContextRepository; @@ -25,13 +28,19 @@ public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; private final UserAccountRepository userAccountRepository; + private final ApiResponseFactory apiResponseFactory; + private final ObjectMapper objectMapper; private final boolean enforceActiveUserCheck; public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, UserAccountRepository userAccountRepository, + ApiResponseFactory apiResponseFactory, + ObjectMapper objectMapper, @Value("${skillhub.auth.enforce-active-user-check:true}") boolean enforceActiveUserCheck) { this.namespaceMemberRepository = namespaceMemberRepository; this.userAccountRepository = userAccountRepository; + this.apiResponseFactory = apiResponseFactory; + this.objectMapper = objectMapper; this.enforceActiveUserCheck = enforceActiveUserCheck; } @@ -44,7 +53,12 @@ protected void doFilterInternal( if (principal != null) { if (isInactiveUser(principal.userId())) { clearAuthentication(request); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue( + response.getOutputStream(), + apiResponseFactory.error(HttpServletResponse.SC_UNAUTHORIZED, "error.auth.local.accountDisabled") + ); return; } request.setAttribute("userId", principal.userId()); 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 01d69e5e..33753448 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -7,17 +8,19 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpSession; +import java.util.List; +import java.util.Locale; +import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; - -import java.util.List; -import java.util.Set; +import org.springframework.context.support.StaticMessageSource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -31,7 +34,20 @@ class AuthContextFilterTest { private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); - private final AuthContextFilter filter = new AuthContextFilter(namespaceMemberRepository, userAccountRepository, true); + private final AuthContextFilter filter; + + AuthContextFilterTest() { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled"); + ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource); + filter = new AuthContextFilter( + namespaceMemberRepository, + userAccountRepository, + apiResponseFactory, + new ObjectMapper(), + true + ); + } @AfterEach void clearSecurityContext() { @@ -59,6 +75,7 @@ void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Excepti filter.doFilter(request, response, filterChain); assertEquals(401, response.getStatus()); + assertTrue(response.getContentAsString().contains("This account has been disabled")); assertTrue(!request.isRequestedSessionIdValid() || request.getSession(false) == null); assertNull(SecurityContextHolder.getContext().getAuthentication()); verify(filterChain, never()).doFilter(request, response); diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 12860cea..85f7a78c 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -157,8 +157,9 @@ const skillsRoute = createRoute({ const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', - validateSearch: (search: Record) => ({ + validateSearch: (search: Record): { returnTo: string; reason?: string } => ({ returnTo: typeof search.returnTo === 'string' ? search.returnTo : '', + reason: typeof search.reason === 'string' ? search.reason : undefined, }), component: LoginPage, }) diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index c31c506a..b22c722e 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -25,6 +25,7 @@ export function LoginPage() { const { data: authMethods } = useAuthMethods(search.returnTo) const returnTo = search.returnTo && search.returnTo.startsWith('/') ? search.returnTo : '/dashboard' + const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null const directMethod = directAuthConfig.provider ? authMethods?.find((method) => method.methodType === 'DIRECT_PASSWORD' && method.provider === directAuthConfig.provider) @@ -71,6 +72,11 @@ export function LoginPage() {
+ {disabledMessage ? ( +
+ {disabledMessage} +
+ ) : null} navigate({ to: returnTo })} diff --git a/web/src/shared/lib/api-error.test.ts b/web/src/shared/lib/api-error.test.ts index 7b02adb2..854c78c4 100644 --- a/web/src/shared/lib/api-error.test.ts +++ b/web/src/shared/lib/api-error.test.ts @@ -37,6 +37,15 @@ describe('handleApiError', () => { expect(window.location.href).toBe('/login') }) + it('preserves disabled-account reason when redirecting to login', async () => { + const { ApiError, handleApiError } = await import('./api-error') + + handleApiError(new ApiError('This account has been disabled', 401, 'This account has been disabled')) + + expect(errorSpy).not.toHaveBeenCalled() + expect(window.location.href).toBe('/login?reason=accountDisabled') + }) + it('falls back to the server message for non-standard api errors', async () => { const { ApiError, handleApiError } = await import('./api-error') diff --git a/web/src/shared/lib/api-error.ts b/web/src/shared/lib/api-error.ts index 402ad7d9..0f772cc6 100644 --- a/web/src/shared/lib/api-error.ts +++ b/web/src/shared/lib/api-error.ts @@ -1,6 +1,8 @@ import i18n from '@/i18n/config' import { toast } from './toast' +const ACCOUNT_DISABLED_REASON = 'accountDisabled' + function resolveLocalizedMessage(message?: string): string | undefined { if (!message) { return undefined @@ -23,6 +25,25 @@ export class ApiError extends Error { } } +function isAccountDisabledError(error: ApiError): boolean { + const accountDisabledMessages = [ + i18n.t('apiError.auth.accountDisabled'), + i18n.getFixedT('en')('apiError.auth.accountDisabled'), + i18n.getFixedT('zh')('apiError.auth.accountDisabled'), + ] + const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase() + const normalizedMessage = error.message.toLowerCase() + + return error.serverMessageKey === 'error.auth.local.accountDisabled' + || error.serverMessage === 'error.auth.local.accountDisabled' + || accountDisabledMessages.includes(error.serverMessage ?? '') + || accountDisabledMessages.includes(error.message) + || normalizedServerMessage.includes('disabled') + || normalizedMessage.includes('disabled') + || (error.serverMessage ?? '').includes('禁用') + || error.message.includes('禁用') +} + export function handleApiError(error: unknown): void { if (!(error instanceof ApiError)) { toast.error(i18n.t('apiError.unknown')) @@ -32,6 +53,10 @@ export function handleApiError(error: unknown): void { const { status } = error if (status === 401) { + if (isAccountDisabledError(error)) { + window.location.href = `/login?reason=${ACCOUNT_DISABLED_REASON}` + return + } toast.error(i18n.t('apiError.unauthorized')) window.location.href = '/login' return From 4ea280c650d5bd0b9017d9f884c815452c595392 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 19:35:28 +0800 Subject: [PATCH 04/14] fix: mute report input placeholder text --- web/src/shared/ui/input.test.ts | 9 +++++++++ web/src/shared/ui/input.tsx | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 web/src/shared/ui/input.test.ts diff --git a/web/src/shared/ui/input.test.ts b/web/src/shared/ui/input.test.ts new file mode 100644 index 00000000..2f1d1b2a --- /dev/null +++ b/web/src/shared/ui/input.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { INPUT_BASE_CLASS_NAME } from './input' + +describe('INPUT_BASE_CLASS_NAME', () => { + it('uses muted placeholder styling', () => { + expect(INPUT_BASE_CLASS_NAME).toContain('placeholder:text-muted-foreground') + expect(INPUT_BASE_CLASS_NAME).not.toContain('placeholder:text-[var(--text-placeholder)]') + }) +}) diff --git a/web/src/shared/ui/input.tsx b/web/src/shared/ui/input.tsx index e5088693..c2b09746 100644 --- a/web/src/shared/ui/input.tsx +++ b/web/src/shared/ui/input.tsx @@ -3,13 +3,16 @@ import { cn } from '@/shared/lib/utils' export interface InputProps extends React.InputHTMLAttributes {} +export const INPUT_BASE_CLASS_NAME = + 'flex h-11 w-full rounded-lg border bg-white px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50' + const Input = React.forwardRef( ({ className, type, style, ...props }, ref) => { return ( Date: Tue, 17 Mar 2026 19:46:02 +0800 Subject: [PATCH 05/14] fix: return skill detail to my skills page --- web/src/app/router.tsx | 3 +++ web/src/pages/dashboard/my-skills.tsx | 5 ++++- web/src/pages/skill-detail.tsx | 10 ++++++++-- web/src/shared/lib/skill-navigation.test.ts | 12 +++++++++++- web/src/shared/lib/skill-navigation.ts | 4 ++++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 85f7a78c..f70f0e1b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -208,6 +208,9 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', + validateSearch: (search: Record): { returnTo?: string } => ({ + returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, + }), component: SkillDetailPage, }) diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index dcf79616..760a4b9e 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -41,7 +41,10 @@ export function MySkillsPage() { const submitPromotionMutation = useSubmitPromotion() const handleSkillClick = (namespace: string, slug: string) => { - navigate({ to: `/space/${namespace}/${slug}` }) + navigate({ + to: `/space/${namespace}/${slug}`, + search: { returnTo: '/dashboard/skills' }, + }) } const resolveStatusLabel = (status?: string) => { diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index e3914d3e..be795a5b 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' +import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' import { ArrowLeft } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' @@ -14,7 +14,7 @@ import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' import { formatLocalDateTime } from '@/shared/lib/date-time' import { incrementSkillDownloadCount } from '@/shared/lib/skill-download-cache' -import { getSkillSquareSearch } from '@/shared/lib/skill-navigation' +import { getSkillSquareSearch, normalizeSkillDetailReturnTo } from '@/shared/lib/skill-navigation' import { formatCompactCount } from '@/shared/lib/number-format' import { resolveDocumentationFilePath } from '@/shared/lib/skill-documentation' import { NamespaceBadge } from '@/shared/components/namespace-badge' @@ -75,6 +75,7 @@ export function SkillDetailPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() const location = useRouterState({ select: (s) => s.location }) + const search = useSearch({ from: '/space/$namespace/$slug' }) const queryClient = useQueryClient() const [reportDialogOpen, setReportDialogOpen] = useState(false) const [reportReason, setReportReason] = useState('') @@ -211,6 +212,11 @@ export function SkillDetailPage() { } const handleBack = () => { + const returnTo = normalizeSkillDetailReturnTo(search.returnTo) + if (returnTo) { + navigate({ to: returnTo }) + return + } navigate({ to: '/search', search: getSkillSquareSearch() }) } diff --git a/web/src/shared/lib/skill-navigation.test.ts b/web/src/shared/lib/skill-navigation.test.ts index 396f6b7f..ccdc6be4 100644 --- a/web/src/shared/lib/skill-navigation.test.ts +++ b/web/src/shared/lib/skill-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getSkillSquareSearch } from './skill-navigation' +import { getSkillSquareSearch, normalizeSkillDetailReturnTo } from './skill-navigation' describe('getSkillSquareSearch', () => { it('returns the default search params for the skill square', () => { @@ -11,3 +11,13 @@ describe('getSkillSquareSearch', () => { }) }) }) + +describe('normalizeSkillDetailReturnTo', () => { + it('returns the provided dashboard route when coming from my skills', () => { + expect(normalizeSkillDetailReturnTo('/dashboard/skills')).toBe('/dashboard/skills') + }) + + it('drops invalid return targets', () => { + expect(normalizeSkillDetailReturnTo('https://example.com/elsewhere')).toBeUndefined() + }) +}) diff --git a/web/src/shared/lib/skill-navigation.ts b/web/src/shared/lib/skill-navigation.ts index f3a4de05..90a8d9d4 100644 --- a/web/src/shared/lib/skill-navigation.ts +++ b/web/src/shared/lib/skill-navigation.ts @@ -6,3 +6,7 @@ export function getSkillSquareSearch() { starredOnly: false, } } + +export function normalizeSkillDetailReturnTo(returnTo?: string) { + return returnTo && returnTo.startsWith('/') ? returnTo : undefined +} From 4d39163adc34cb46aadf46cc6f0943f874daf3d9 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Tue, 17 Mar 2026 19:49:56 +0800 Subject: [PATCH 06/14] test: stabilize auth context filter coverage --- .../iflytek/skillhub/filter/AuthContextFilterTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 33753448..5b3a1fc8 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 @@ -1,6 +1,7 @@ package com.iflytek.skillhub.filter; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.context.support.StaticMessageSource; @@ -44,7 +46,7 @@ class AuthContextFilterTest { namespaceMemberRepository, userAccountRepository, apiResponseFactory, - new ObjectMapper(), + new ObjectMapper().registerModule(new JavaTimeModule()), true ); } @@ -61,7 +63,7 @@ void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Excepti user.setStatus(UserStatus.DISABLED); MockHttpServletRequest request = new MockHttpServletRequest(); - HttpSession session = request.getSession(true); + MockHttpSession session = (MockHttpSession) request.getSession(true); session.setAttribute("platformPrincipal", principal); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, List.of()) @@ -75,8 +77,8 @@ void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Excepti filter.doFilter(request, response, filterChain); assertEquals(401, response.getStatus()); - assertTrue(response.getContentAsString().contains("This account has been disabled")); - assertTrue(!request.isRequestedSessionIdValid() || request.getSession(false) == null); + assertTrue(response.getContentAsString().contains("\"code\":401")); + assertTrue(session.isInvalid()); assertNull(SecurityContextHolder.getContext().getAuthentication()); verify(filterChain, never()).doFilter(request, response); } From 7dae434664eb2265f9c684af582645dbd0896a88 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 14:50:54 +0800 Subject: [PATCH 07/14] feat(publish): increase single file limit to 10MB --- .../com/iflytek/skillhub/config/SkillPublishProperties.java | 2 +- server/skillhub-app/src/main/resources/application.yml | 2 +- .../skillhub/domain/skill/validation/SkillPackagePolicy.java | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java index 7d5fadd7..38948663 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -11,7 +11,7 @@ public class SkillPublishProperties { private int maxFileCount = 100; - private long maxSingleFileSize = 1024 * 1024; + private long maxSingleFileSize = 10 * 1024 * 1024; // 10MB private long maxPackageSize = 100 * 1024 * 1024; private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( ".md", ".txt", ".json", ".yaml", ".yml", diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 285f5c98..7d31292f 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -99,7 +99,7 @@ skillhub: anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production} publish: max-file-count: 100 - max-single-file-size: 1048576 # 1MB + max-single-file-size: 10485760 # 10MB max-package-size: 104857600 # 100MB allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg device-auth: diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 38ff4c23..9e935532 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -12,8 +12,8 @@ public final class SkillPackagePolicy { public static final int MAX_FILE_COUNT = 100; - public static final long MAX_SINGLE_FILE_SIZE = 1024 * 1024; // 1MB - public static final long MAX_TOTAL_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB + public static final long MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024; // 10MB + public static final long MAX_TOTAL_PACKAGE_SIZE = 100 * 1024 * 1024; // 100MB public static final String SKILL_MD_PATH = "SKILL.md"; public static final Set ALLOWED_EXTENSIONS = Set.of( ".md", ".txt", ".json", ".yaml", ".yml", From e55947d3c3f0832d71a8e9be5bbeb70d696e3e64 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 14:52:06 +0800 Subject: [PATCH 08/14] feat(publish): expand allowed file extensions --- .../skillhub/config/SkillPublishProperties.java | 8 +++++--- .../skillhub-app/src/main/resources/application.yml | 2 +- .../domain/skill/validation/SkillPackagePolicy.java | 12 +++++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java index 38948663..98cb6de7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillPublishProperties.java @@ -14,9 +14,11 @@ public class SkillPublishProperties { private long maxSingleFileSize = 10 * 1024 * 1024; // 10MB private long maxPackageSize = 100 * 1024 * 1024; private Set allowedFileExtensions = new LinkedHashSet<>(Set.of( - ".md", ".txt", ".json", ".yaml", ".yml", - ".js", ".ts", ".py", ".sh", - ".png", ".jpg", ".svg" + ".md", ".txt", ".json", ".yaml", ".yml", ".html", ".css", ".csv", ".pdf", + ".toml", ".xml", ".ini", ".cfg", ".env", + ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", + ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", + ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico" )); public int getMaxFileCount() { diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 7d31292f..e6b561f9 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -101,7 +101,7 @@ skillhub: max-file-count: 100 max-single-file-size: 10485760 # 10MB max-package-size: 104857600 # 100MB - allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.js,.ts,.py,.sh,.png,.jpg,.svg + allowed-file-extensions: .md,.txt,.json,.yaml,.yml,.html,.css,.csv,.pdf,.toml,.xml,.ini,.cfg,.env,.js,.ts,.py,.sh,.rb,.go,.rs,.java,.kt,.lua,.sql,.r,.bat,.ps1,.zsh,.bash,.png,.jpg,.jpeg,.svg,.gif,.webp,.ico device-auth: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} bootstrap: diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 9e935532..39623b56 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -16,9 +16,15 @@ public final class SkillPackagePolicy { public static final long MAX_TOTAL_PACKAGE_SIZE = 100 * 1024 * 1024; // 100MB public static final String SKILL_MD_PATH = "SKILL.md"; public static final Set ALLOWED_EXTENSIONS = Set.of( - ".md", ".txt", ".json", ".yaml", ".yml", - ".js", ".ts", ".py", ".sh", - ".png", ".jpg", ".svg" + // 文档 + ".md", ".txt", ".json", ".yaml", ".yml", ".html", ".css", ".csv", ".pdf", + // 配置 + ".toml", ".xml", ".ini", ".cfg", ".env", + // 脚本/语言 + ".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt", + ".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash", + // 图片 + ".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico" ); private SkillPackagePolicy() { From 81edb08807816ef400333a6b2814a8a835088983 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 14:56:02 +0800 Subject: [PATCH 09/14] feat(publish): extend secret scanning to new text file types --- .../validation/BasicPrePublishValidator.java | 22 ++++++------ .../validation/SkillPackageValidatorTest.java | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java index c464b4d4..86dca42c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -61,16 +61,18 @@ public ValidationResult validate(SkillPackageContext context) { private boolean isTextLike(String path) { String lowerPath = path.toLowerCase(Locale.ROOT); - return lowerPath.endsWith(".md") - || lowerPath.endsWith(".txt") - || lowerPath.endsWith(".json") - || lowerPath.endsWith(".yaml") - || lowerPath.endsWith(".yml") - || lowerPath.endsWith(".js") - || lowerPath.endsWith(".ts") - || lowerPath.endsWith(".py") - || lowerPath.endsWith(".sh") - || lowerPath.endsWith(".svg"); + return lowerPath.endsWith(".md") || lowerPath.endsWith(".txt") + || lowerPath.endsWith(".json") || lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml") + || lowerPath.endsWith(".js") || lowerPath.endsWith(".ts") + || lowerPath.endsWith(".py") || lowerPath.endsWith(".sh") || lowerPath.endsWith(".svg") + || lowerPath.endsWith(".html") || lowerPath.endsWith(".css") || lowerPath.endsWith(".csv") + || lowerPath.endsWith(".toml") || lowerPath.endsWith(".xml") || lowerPath.endsWith(".ini") + || lowerPath.endsWith(".cfg") || lowerPath.endsWith(".env") + || lowerPath.endsWith(".rb") || lowerPath.endsWith(".go") || lowerPath.endsWith(".rs") + || lowerPath.endsWith(".java") || lowerPath.endsWith(".kt") || lowerPath.endsWith(".lua") + || lowerPath.endsWith(".sql") || lowerPath.endsWith(".r") + || lowerPath.endsWith(".bat") || lowerPath.endsWith(".ps1") + || lowerPath.endsWith(".zsh") || lowerPath.endsWith(".bash"); } private boolean isPlaceholderValue(String value) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index 535ca17c..b923aae9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -288,4 +288,40 @@ void testInvalidSvgPayloadRejected() { assertFalse(result.passed()); assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension"))); } + + @Test + void rejectsJpegWithWrongMagicBytes() { + List entries = List.of( + skillMdEntry(), + new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg") + ); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg"))); + } + + @Test + void acceptsValidGif() { + byte[] gifHeader = "GIF89a".getBytes(); + byte[] content = new byte[20]; + System.arraycopy(gifHeader, 0, content, 0, gifHeader.length); + List entries = List.of( + skillMdEntry(), + new PackageEntry("anim.gif", content, content.length, "image/gif") + ); + ValidationResult result = validator.validate(entries); + assertTrue(result.passed()); + } + + private PackageEntry skillMdEntry() { + String skillMdContent = """ + --- + name: test-skill + description: A test skill + version: 1.0.0 + --- + Body + """; + return new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + } } From fc53107ad785d71263ec58056ca3b37d435d42fa Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 15:00:40 +0800 Subject: [PATCH 10/14] feat(publish): add content validation for new file types --- .../skill/validation/SkillPackagePolicy.java | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 39623b56..c4520a54 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -84,6 +84,33 @@ public static String validateContentMatchesExtension(String path, byte[] content String text = new String(content, StandardCharsets.UTF_8).trim().toLowerCase(); return text.contains("= 12 + && hasPrefix(content, 'R', 'I', 'F', 'F') + && content[8] == 'W' && content[9] == 'E' && content[10] == 'B' && content[11] == 'P') + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".ico")) { + return hasPrefix(content, 0x00, 0x00, 0x01, 0x00) + ? null + : "File content does not match extension: " + path; + } + if (lowerPath.endsWith(".pdf")) { + return hasPrefix(content, '%', 'P', 'D', 'F') + ? null + : "File content does not match extension: " + path; + } if (isTextExtension(lowerPath)) { return isUtf8Text(content) ? null : "File content does not match extension: " + path; } @@ -91,15 +118,17 @@ public static String validateContentMatchesExtension(String path, byte[] content } private static boolean isTextExtension(String path) { - return path.endsWith(".md") - || path.endsWith(".txt") - || path.endsWith(".json") - || path.endsWith(".yaml") - || path.endsWith(".yml") - || path.endsWith(".js") - || path.endsWith(".ts") - || path.endsWith(".py") - || path.endsWith(".sh"); + return path.endsWith(".md") || path.endsWith(".txt") + || path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml") + || path.endsWith(".js") || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh") + || path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".csv") + || path.endsWith(".toml") || path.endsWith(".xml") || path.endsWith(".ini") + || path.endsWith(".cfg") || path.endsWith(".env") + || path.endsWith(".rb") || path.endsWith(".go") || path.endsWith(".rs") + || path.endsWith(".java") || path.endsWith(".kt") || path.endsWith(".lua") + || path.endsWith(".sql") || path.endsWith(".r") + || path.endsWith(".bat") || path.endsWith(".ps1") + || path.endsWith(".zsh") || path.endsWith(".bash"); } private static boolean isUtf8Text(byte[] content) { From 7e869faf8d1affaf8942c3353420f70976a507bd Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 15:03:07 +0800 Subject: [PATCH 11/14] refactor(publish): inject configurable limits into SkillPackageArchiveExtractor --- .../support/SkillPackageArchiveExtractor.java | 27 ++++++++---- .../SkillPackageArchiveExtractorTest.java | 41 ++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index b9193b8e..6c7404f5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.controller.support; +import com.iflytek.skillhub.config.SkillPublishProperties; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; import org.springframework.stereotype.Component; @@ -15,11 +16,21 @@ @Component public class SkillPackageArchiveExtractor { + private final long maxTotalPackageSize; + private final long maxSingleFileSize; + private final int maxFileCount; + + public SkillPackageArchiveExtractor(SkillPublishProperties properties) { + this.maxTotalPackageSize = properties.getMaxPackageSize(); + this.maxSingleFileSize = properties.getMaxSingleFileSize(); + this.maxFileCount = properties.getMaxFileCount(); + } + public List extract(MultipartFile file) throws IOException { - if (file.getSize() > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + if (file.getSize() > maxTotalPackageSize) { throw new IllegalArgumentException( "Package too large: " + file.getSize() + " bytes (max: " - + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")" + + maxTotalPackageSize + ")" ); } @@ -34,19 +45,19 @@ public List extract(MultipartFile file) throws IOException { continue; } - if (entries.size() >= SkillPackagePolicy.MAX_FILE_COUNT) { + if (entries.size() >= maxFileCount) { throw new IllegalArgumentException( - "Too many files: more than " + SkillPackagePolicy.MAX_FILE_COUNT + "Too many files: more than " + maxFileCount ); } String normalizedPath = SkillPackagePolicy.normalizeEntryPath(zipEntry.getName()); byte[] content = readEntry(zis, normalizedPath); totalSize += content.length; - if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + if (totalSize > maxTotalPackageSize) { throw new IllegalArgumentException( "Package too large: " + totalSize + " bytes (max: " - + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")" + + maxTotalPackageSize + ")" ); } @@ -70,10 +81,10 @@ private byte[] readEntry(ZipInputStream zis, String path) throws IOException { int read; while ((read = zis.read(buffer)) != -1) { totalRead += read; - if (totalRead > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + if (totalRead > maxSingleFileSize) { throw new IllegalArgumentException( "File too large: " + path + " (" + totalRead + " bytes, max: " - + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")" + + maxSingleFileSize + ")" ); } outputStream.write(buffer, 0, read); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index d5464a60..35227cf4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -1,19 +1,31 @@ package com.iflytek.skillhub.controller.support; +import com.iflytek.skillhub.config.SkillPublishProperties; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class SkillPackageArchiveExtractorTest { - private final SkillPackageArchiveExtractor extractor = new SkillPackageArchiveExtractor(); + private SkillPackageArchiveExtractor extractor; + + @BeforeEach + void setUp() { + SkillPublishProperties props = new SkillPublishProperties(); + extractor = new SkillPackageArchiveExtractor(props); + } @Test void shouldRejectPathTraversalEntry() throws Exception { @@ -44,6 +56,20 @@ void shouldRejectOversizedZipEntry() throws Exception { assertTrue(error.getMessage().contains("File too large: large.txt")); } + @Test + void respectsConfiguredSingleFileLimit() throws Exception { + SkillPublishProperties props = new SkillPublishProperties(); + props.setMaxSingleFileSize(5 * 1024 * 1024); // 5MB + SkillPackageArchiveExtractor customExtractor = new SkillPackageArchiveExtractor(props); + + byte[] content = new byte[3 * 1024 * 1024]; // 3MB — under 5MB limit + byte[] zip = createZip(Map.of("data.md", content)); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zip); + + List entries = customExtractor.extract(file); + assertEquals(1, entries.size()); + } + private byte[] createZip(String entryName, String content) throws Exception { return createZip(entryName, content.getBytes(StandardCharsets.UTF_8)); } @@ -58,4 +84,17 @@ private byte[] createZip(String entryName, byte[] content) throws Exception { } return baos.toByteArray(); } + + private byte[] createZip(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + ZipEntry entry = new ZipEntry(e.getKey()); + zos.putNextEntry(entry); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } } From dac7fa1eac18feb14c39fff4e6d1560f0bd9d933 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 15:10:46 +0800 Subject: [PATCH 12/14] feat(publish): support zip with single root directory wrapper --- .../support/SkillPackageArchiveExtractor.java | 35 +++++++++++- .../support/ZipPackageExtractor.java | 2 +- .../SkillPackageArchiveExtractorTest.java | 55 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 6c7404f5..dc221941 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -9,7 +9,9 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -71,7 +73,38 @@ public List extract(MultipartFile file) throws IOException { } } - return entries; + return stripSingleRootDirectory(entries); + } + + /** + * If all file paths share a single root directory prefix (e.g., "my-skill/xxx"), + * strip that prefix. Otherwise return entries unchanged. + */ + static List stripSingleRootDirectory(List entries) { + if (entries.isEmpty()) return entries; + + Set rootSegments = new HashSet<>(); + for (PackageEntry entry : entries) { + int slashIndex = entry.path().indexOf('/'); + if (slashIndex < 0) { + // File at root level, no stripping + return entries; + } + rootSegments.add(entry.path().substring(0, slashIndex)); + } + + if (rootSegments.size() != 1) { + return entries; + } + + String prefix = rootSegments.iterator().next() + "/"; + return entries.stream() + .map(e -> new PackageEntry( + e.path().substring(prefix.length()), + e.content(), + e.size(), + e.contentType())) + .toList(); } private byte[] readEntry(ZipInputStream zis, String path) throws IOException { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index f9791115..145e4ef2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -69,7 +69,7 @@ public List extract(MultipartFile file) throws IOException { } } - return entries; + return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries); } private byte[] readEntry(ZipInputStream zis, String path) throws IOException { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index 35227cf4..7a1bd820 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -70,6 +70,61 @@ void respectsConfiguredSingleFileLimit() throws Exception { assertEquals(1, entries.size()); } + @Test + void stripsRootDirectoryWhenSingleFolder() throws Exception { + byte[] zipBytes = createZip(Map.of( + "my-skill/SKILL.md", "---\nname: test\n---\n".getBytes(), + "my-skill/config.json", "{}".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("config.json"))); + } + + @Test + void doesNotStripWhenMultipleRootEntries() throws Exception { + byte[] zipBytes = createZip(Map.of( + "SKILL.md", "---\nname: test\n---\n".getBytes(), + "config.json", "{}".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("SKILL.md"))); + } + + @Test + void stripsRootDirectoryWhenZipHasExplicitDirEntry() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry("my-skill/")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("my-skill/SKILL.md")); + zos.write("---\nname: test\n---".getBytes()); + zos.closeEntry(); + } + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", baos.toByteArray()); + List entries = extractor.extract(file); + + assertEquals(1, entries.size()); + assertEquals("SKILL.md", entries.get(0).path()); + } + + @Test + void doesNotStripWhenMultipleRootDirectories() throws Exception { + byte[] zipBytes = createZip(Map.of( + "dir-a/SKILL.md", "---\nname: test\n---\n".getBytes(), + "dir-b/other.md", "# other".getBytes() + )); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zipBytes); + List entries = extractor.extract(file); + + assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-a/SKILL.md"))); + assertTrue(entries.stream().anyMatch(e -> e.path().equals("dir-b/other.md"))); + } + private byte[] createZip(String entryName, String content) throws Exception { return createZip(entryName, content.getBytes(StandardCharsets.UTF_8)); } From ce25d1997dbb90fbb5871790cd65e8a40d49165c Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 15:12:22 +0800 Subject: [PATCH 13/14] feat(publish): expand determineContentType for new file types --- .../support/SkillPackageArchiveExtractor.java | 26 +++++++++++++++---- .../support/ZipPackageExtractor.java | 26 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index dc221941..9becbdd2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -126,11 +126,27 @@ private byte[] readEntry(ZipInputStream zis, String path) throws IOException { } private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; return "application/octet-stream"; } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java index 145e4ef2..5d3a7a80 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/ZipPackageExtractor.java @@ -117,11 +117,27 @@ private String normalizeEntryPath(String path) { } private String determineContentType(String filename) { - if (filename.endsWith(".py")) return "text/x-python"; - if (filename.endsWith(".json")) return "application/json"; - if (filename.endsWith(".yaml") || filename.endsWith(".yml")) return "application/x-yaml"; - if (filename.endsWith(".txt")) return "text/plain"; - if (filename.endsWith(".md")) return "text/markdown"; + String lower = filename.toLowerCase(); + if (lower.endsWith(".py")) return "text/x-python"; + if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml"; + if (lower.endsWith(".txt")) return "text/plain"; + if (lower.endsWith(".md")) return "text/markdown"; + if (lower.endsWith(".html")) return "text/html"; + if (lower.endsWith(".css")) return "text/css"; + if (lower.endsWith(".csv")) return "text/csv"; + if (lower.endsWith(".xml")) return "application/xml"; + if (lower.endsWith(".js")) return "text/javascript"; + if (lower.endsWith(".ts")) return "text/typescript"; + if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".toml")) return "application/toml"; return "application/octet-stream"; } } From 12318203f7a331ff66f85fed870d58ec89fcc845 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Wed, 18 Mar 2026 15:18:09 +0800 Subject: [PATCH 14/14] test(publish): update tests for new upload constraints --- .../SkillPackageArchiveExtractorTest.java | 17 +++--- .../validation/SkillPackageValidatorTest.java | 61 ++++++------------- 2 files changed, 26 insertions(+), 52 deletions(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java index 7a1bd820..403be311 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractorTest.java @@ -43,15 +43,16 @@ void shouldRejectPathTraversalEntry() throws Exception { @Test void shouldRejectOversizedZipEntry() throws Exception { - byte[] content = new byte[1024 * 1024 + 1]; - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - createZip("large.txt", content) - ); + SkillPublishProperties props = new SkillPublishProperties(); + props.setMaxSingleFileSize(1024); // 1KB limit + SkillPackageArchiveExtractor smallExtractor = new SkillPackageArchiveExtractor(props); - IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> extractor.extract(file)); + byte[] content = new byte[1025]; // >1KB + byte[] zip = createZip(Map.of("large.txt", content)); + MockMultipartFile file = new MockMultipartFile("file", "test.zip", "application/zip", zip); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> smallExtractor.extract(file)); assertTrue(error.getMessage().contains("File too large: large.txt")); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index b923aae9..44c57254 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -76,26 +76,18 @@ void testDisallowedExtension() { @Test void testFileTooLarge() { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - Body - """; - - byte[] largeContent = new byte[2 * 1024 * 1024]; // 2MB - + // Use a custom validator with 1KB single file limit to test the logic + SkillPackageValidator smallValidator = new SkillPackageValidator( + new SkillMetadataParser(), 100, 1024, 100 * 1024 * 1024, + SkillPackagePolicy.ALLOWED_EXTENSIONS); + byte[] bigContent = new byte[1025]; // >1KB List entries = List.of( - new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), - new PackageEntry("large.txt", largeContent, largeContent.length, "text/plain") + skillMdEntry(), + new PackageEntry("big.txt", bigContent, bigContent.length, "text/plain") ); - - ValidationResult result = validator.validate(entries); - + ValidationResult result = smallValidator.validate(entries); assertFalse(result.passed()); - assertTrue(result.errors().stream().anyMatch(e -> e.contains("File too large") && e.contains("large.txt"))); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("File too large"))); } @Test @@ -165,35 +157,16 @@ void testInvalidYamlFrontmatterWithColonInValueShouldStillPass() { @Test void testPackageTooLarge() { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - Body - """; - - byte[] largeContent = new byte[900 * 1024]; // 900KB each - + // Use a custom validator with 2KB total limit to test the logic + SkillPackageValidator smallValidator = new SkillPackageValidator( + new SkillMetadataParser(), 100, 10 * 1024 * 1024, 2048, + SkillPackagePolicy.ALLOWED_EXTENSIONS); + byte[] content = new byte[2000]; // 2KB List entries = List.of( - new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"), - new PackageEntry("file1.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file2.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file3.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file4.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file5.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file6.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file7.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file8.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file9.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file10.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file11.txt", largeContent, largeContent.length, "text/plain"), - new PackageEntry("file12.txt", largeContent, largeContent.length, "text/plain") + skillMdEntry(), // ~50 bytes + new PackageEntry("data.txt", content, content.length, "text/plain") ); - - ValidationResult result = validator.validate(entries); - + ValidationResult result = smallValidator.validate(entries); assertFalse(result.passed()); assertTrue(result.errors().stream().anyMatch(e -> e.contains("Package too large"))); }