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:
XiaoSeS 2026-03-24 20:26:50 +08:00 • committed by GitHub
parent 37c188ab7d
commit 6020006a25
14 changed files with 113 additions and 7 deletions

View file

@ -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;

View file

@ -273,6 +273,10 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public void flush() {
}
@Override
public void deleteBySkillId(Long skillId) {
throw unsupported();

View file

@ -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);
}

View file

@ -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);

View file

@ -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";

View file

@ -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')

View file

@ -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) {

View file

@ -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)

View 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')
})
})

View file

@ -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",

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View 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)
}