From 6020006a25d20036adeaddf55bd78632c9e4815d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:26:50 +0800 Subject: [PATCH] fix: scanner redis sentinel, publish flush, version delete, clipboard fallback (#157) * fix(scanner): migrate scan stream to redisson * fix(web): update security audit pending labels * fix(scanner): support redis sentinel in redisson config * fix(db): allow deleting skill versions with audit history * fix(publish): flush replaced versions before recreation * fix(i18n): localize agent setup command in Chinese locale Translate the agent quick-start prompt from English to Chinese and add a test to verify both locale strings. * fix(clipboard): add fallback for insecure contexts navigator.clipboard is undefined in HTTP/iframe/WebView contexts, causing TypeError on copy. Extract copyToClipboard() utility with execCommand fallback and apply across all 6 call sites. --- ...__drop_security_audit_skill_version_fk.sql | 6 +++ .../skillhub/stream/ScanTaskConsumerTest.java | 4 ++ .../domain/skill/SkillVersionRepository.java | 1 + .../skill/service/SkillPublishService.java | 1 + .../service/SkillPublishServiceTest.java | 54 +++++++++++++++++++ .../features/skill/file-preview-dialog.tsx | 9 +++- web/src/features/skill/install-command.tsx | 3 +- .../features/token/create-token-dialog.tsx | 3 +- .../i18n/landing-quick-start-locale.test.ts | 10 ++++ web/src/i18n/locales/zh.json | 2 +- web/src/shared/components/copy-button.tsx | 3 +- .../shared/components/landing-quick-start.tsx | 3 +- web/src/shared/components/quick-start.tsx | 3 +- web/src/shared/lib/clipboard.ts | 18 +++++++ 14 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V38__drop_security_audit_skill_version_fk.sql create mode 100644 web/src/i18n/landing-quick-start-locale.test.ts create mode 100644 web/src/shared/lib/clipboard.ts diff --git a/server/skillhub-app/src/main/resources/db/migration/V38__drop_security_audit_skill_version_fk.sql b/server/skillhub-app/src/main/resources/db/migration/V38__drop_security_audit_skill_version_fk.sql new file mode 100644 index 00000000..16e67523 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V38__drop_security_audit_skill_version_fk.sql @@ -0,0 +1,6 @@ +-- Allow soft-deleted security audit history to survive skill version deletion. +-- The application stores skill_version_id as a plain identifier and intentionally +-- soft deletes security_audit rows before removing draft/rejected versions. + +ALTER TABLE security_audit + DROP CONSTRAINT IF EXISTS security_audit_skill_version_id_fkey; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index c51d9d22..ba1bc086 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -273,6 +273,10 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public void flush() { + } + @Override public void deleteBySkillId(Long skillId) { throw unsupported(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index 33878b0e..6565436f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -16,5 +16,6 @@ public interface SkillVersionRepository { List findBySkillIdAndStatus(Long skillId, SkillVersionStatus status); SkillVersion save(SkillVersion version); void delete(SkillVersion version); + void flush(); void deleteBySkillId(Long skillId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 85d9c7c5..9dc08b27 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -398,6 +398,7 @@ public class SkillPublishService { skillFileRepository.deleteByVersionId(version.getId()); securityScanService.softDeleteByVersionId(version.getId()); skillVersionRepository.delete(version); + skillVersionRepository.flush(); if (version.getId().equals(skill.getLatestVersionId())) { skill.setLatestVersionId(null); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 8e6cfc98..495d7b94 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -214,10 +215,63 @@ class SkillPublishServiceTest { assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); verify(skillFileRepository).deleteByVersionId(8L); verify(skillVersionRepository).delete(draftVersion); + verify(skillVersionRepository).flush(); verify(objectStorageService).deleteObjects(List.of("skills/1/8/SKILL.md")); verify(objectStorageService).deleteObject("packages/1/8/bundle.zip"); } + @Test + void testPublishFromEntries_ShouldFlushDeletedVersionBeforeSavingReplacement() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0-beta\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + setId(namespace, 1L); + NamespaceMember member = mock(NamespaceMember.class); + SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0-beta", "Body", Map.of()); + + Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC); + setId(skill, 1L); + SkillVersion draftVersion = new SkillVersion(1L, "1.0.0-beta", publisherId); + draftVersion.setStatus(SkillVersionStatus.DRAFT); + setId(draftVersion, 8L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member)); + when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass()); + when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill)); + when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0-beta")).thenReturn(Optional.of(draftVersion)); + when(skillFileRepository.findByVersionId(8L)).thenReturn(List.of()); + when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> { + SkillVersion saved = invocation.getArgument(0); + if (saved.getId() == null) { + setId(saved, 10L); + } + return saved; + }); + when(skillRepository.save(any())).thenReturn(skill); + + service.publishFromEntries( + namespaceSlug, + entries, + publisherId, + SkillVisibility.PUBLIC, + Set.of() + ); + + InOrder inOrder = inOrder(skillVersionRepository); + inOrder.verify(skillVersionRepository).delete(draftVersion); + inOrder.verify(skillVersionRepository).flush(); + inOrder.verify(skillVersionRepository, times(2)).save(any(SkillVersion.class)); + } + @Test void testPublishFromEntries_ShouldSlugifyNameBeforeLookupAndResponse() throws Exception { String namespaceSlug = "test-ns"; diff --git a/web/src/features/skill/file-preview-dialog.tsx b/web/src/features/skill/file-preview-dialog.tsx index 5a3cf51d..14bba880 100644 --- a/web/src/features/skill/file-preview-dialog.tsx +++ b/web/src/features/skill/file-preview-dialog.tsx @@ -6,6 +6,7 @@ import { Button } from '@/shared/ui/button' import { MarkdownRenderer } from './markdown-renderer' import { CodeRenderer } from './code-renderer' import { toast } from '@/shared/lib/toast' +import { copyToClipboard } from '@/shared/lib/clipboard' import { getFileTypeLabel, canPreviewFile, getLanguageForHighlight } from './file-type-utils' import type { FileTreeNode } from './file-tree-builder' @@ -55,7 +56,13 @@ export function FilePreviewDialog({ const handleCopy = async () => { if (!content || copyState !== 'idle') return setCopyState('spinning') - await navigator.clipboard.writeText(content) + try { + await copyToClipboard(content) + } catch { + toast.error(t('filePreview.copyError', { defaultValue: t('filePreview.loadError') })) + setCopyState('idle') + return + } // Show checkmark after the spin completes setTimeout(() => { setCopyState('done') diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 124f4352..95369dea 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Check, Copy } from 'lucide-react' import { Button } from '@/shared/ui/button' +import { copyToClipboard } from '@/shared/lib/clipboard' interface InstallCommandProps { namespace: string @@ -39,7 +40,7 @@ export function InstallCommand({ namespace, slug }: InstallCommandProps) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(command) + await copyToClipboard(command) setCopied(true) window.setTimeout(() => setCopied(false), 2000) } catch (err) { diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 432420de..6f0eb7cc 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useMutation, useQueryClient } from '@tanstack/react-query' import { tokenApi } from '@/api/client' +import { copyToClipboard } from '@/shared/lib/clipboard' import { Dialog, DialogContent, @@ -106,7 +107,7 @@ export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenD if (!createdToken) return try { - await navigator.clipboard.writeText(createdToken.token) + await copyToClipboard(createdToken.token) toast.success(t('createToken.copySuccess'), undefined, centeredToastOptions()) } catch (error) { console.error('Failed to copy token:', error) diff --git a/web/src/i18n/landing-quick-start-locale.test.ts b/web/src/i18n/landing-quick-start-locale.test.ts new file mode 100644 index 00000000..17935629 --- /dev/null +++ b/web/src/i18n/landing-quick-start-locale.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import zh from './locales/zh.json' + +describe('landing quick start locales', () => { + it('uses localized agent setup prompts for chinese and english', () => { + expect(zh.landing.quickStart.agent.command).toBe('阅读 https://www.example.com/registry/skill.md,并按照说明完成 SkillHub Skills Registry 的配置') + expect(en.landing.quickStart.agent.command).toBe('Read https://www.example.com/registry/skill.md and follow the instructions to setup SkillHub Skills Registry') + }) +}) diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 2ec0194b..e856bc40 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -107,7 +107,7 @@ }, "agent": { "description": "发送提示词给你的 Agent,以设置SkillHub Registry", - "command": "Read https://www.example.com/registry/skill.md and follow the instructions to setup SkillHub Skills Registry" + "command": "阅读 https://www.example.com/registry/skill.md,并按照说明完成 SkillHub Skills Registry 的配置" }, "human": { "description": "使用CLI工具安装Skills", diff --git a/web/src/shared/components/copy-button.tsx b/web/src/shared/components/copy-button.tsx index 3744f978..36674838 100644 --- a/web/src/shared/components/copy-button.tsx +++ b/web/src/shared/components/copy-button.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' +import { copyToClipboard } from '@/shared/lib/clipboard' interface CopyButtonProps { text: string @@ -13,7 +14,7 @@ export function CopyButton({ text, className }: CopyButtonProps) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(text) + await copyToClipboard(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch (err) { diff --git a/web/src/shared/components/landing-quick-start.tsx b/web/src/shared/components/landing-quick-start.tsx index c333a61a..0040b66c 100644 --- a/web/src/shared/components/landing-quick-start.tsx +++ b/web/src/shared/components/landing-quick-start.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Bot, Check, Copy, UserRound } from 'lucide-react' +import { copyToClipboard } from '@/shared/lib/clipboard' type LandingQuickStartTabId = 'agent' | 'human' @@ -17,7 +18,7 @@ function CompactCopyButton({ text }: { text: string }) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(text) + await copyToClipboard(text) setCopied(true) window.setTimeout(() => setCopied(false), 2000) } catch (err) { diff --git a/web/src/shared/components/quick-start.tsx b/web/src/shared/components/quick-start.tsx index 21986b42..f6a85564 100644 --- a/web/src/shared/components/quick-start.tsx +++ b/web/src/shared/components/quick-start.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { Check, Copy, Settings, Download, Upload } from 'lucide-react' import { useMemo, useState } from 'react' +import { copyToClipboard } from '@/shared/lib/clipboard' function getAppBaseUrl(): string { if (typeof window === 'undefined') { @@ -19,7 +20,7 @@ function CopyButton({ text }: { text: string }) { const handleCopy = async () => { try { - await navigator.clipboard.writeText(text) + await copyToClipboard(text) setCopied(true) window.setTimeout(() => setCopied(false), 2000) } catch (err) { diff --git a/web/src/shared/lib/clipboard.ts b/web/src/shared/lib/clipboard.ts new file mode 100644 index 00000000..f7ab6a05 --- /dev/null +++ b/web/src/shared/lib/clipboard.ts @@ -0,0 +1,18 @@ +/** + * Copy text to clipboard with fallback for insecure contexts (HTTP, iframes). + */ +export async function copyToClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return + } + // Fallback for insecure contexts + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) +}