mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
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.
This commit is contained in:
parent
37c188ab7d
commit
6020006a25
14 changed files with 113 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
@ -273,6 +273,10 @@ class ScanTaskConsumerTest {
|
|||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteBySkillId(Long skillId) {
|
||||
throw unsupported();
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ public interface SkillVersionRepository {
|
|||
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
|
||||
SkillVersion save(SkillVersion version);
|
||||
void delete(SkillVersion version);
|
||||
void flush();
|
||||
void deleteBySkillId(Long skillId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> 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";
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
10
web/src/i18n/landing-quick-start-locale.test.ts
Normal file
10
web/src/i18n/landing-quick-start-locale.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
18
web/src/shared/lib/clipboard.ts
Normal file
18
web/src/shared/lib/clipboard.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Copy text to clipboard with fallback for insecure contexts (HTTP, iframes).
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue