Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eded924
fix: keep download counts consistent across skill pages
yun-zhi-ztl Mar 17, 2026
5e89a4d
fix: stabilize empty search ordering across sorts
yun-zhi-ztl Mar 17, 2026
d6a4831
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 17, 2026
55586ef
fix: show disabled-account reason on login redirect
yun-zhi-ztl Mar 17, 2026
4ea280c
fix: mute report input placeholder text
yun-zhi-ztl Mar 17, 2026
caa6093
fix: return skill detail to my skills page
yun-zhi-ztl Mar 17, 2026
4d39163
test: stabilize auth context filter coverage
yun-zhi-ztl Mar 17, 2026
aa6a9ff
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 18, 2026
2430e2f
Merge remote-tracking branch 'origin/main' into feature/project-fixbug
yun-zhi-ztl Mar 18, 2026
7dae434
feat(publish): increase single file limit to 10MB
yun-zhi-ztl Mar 18, 2026
e55947d
feat(publish): expand allowed file extensions
yun-zhi-ztl Mar 18, 2026
81edb08
feat(publish): extend secret scanning to new text file types
yun-zhi-ztl Mar 18, 2026
fc53107
feat(publish): add content validation for new file types
yun-zhi-ztl Mar 18, 2026
7e869fa
refactor(publish): inject configurable limits into SkillPackageArchiv…
yun-zhi-ztl Mar 18, 2026
dac7fa1
feat(publish): support zip with single root directory wrapper
yun-zhi-ztl Mar 18, 2026
ce25d19
feat(publish): expand determineContentType for new file types
yun-zhi-ztl Mar 18, 2026
1231820
test(publish): update tests for new upload constraints
yun-zhi-ztl Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
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<String> 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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,18 +9,30 @@
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;

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

Expand All @@ -34,19 +47,19 @@ public List<PackageEntry> 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 + ")"
);
}

Expand All @@ -60,7 +73,38 @@ public List<PackageEntry> 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<PackageEntry> stripSingleRootDirectory(List<PackageEntry> entries) {
if (entries.isEmpty()) return entries;

Set<String> 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 {
Expand All @@ -70,10 +114,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);
Expand All @@ -82,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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public List<PackageEntry> extract(MultipartFile file) throws IOException {
}
}

return entries;
return SkillPackageArchiveExtractor.stripSingleRootDirectory(entries);
}

private byte[] readEntry(ZipInputStream zis, String path) throws IOException {
Expand Down Expand Up @@ -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";
}
}
4 changes: 2 additions & 2 deletions server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ 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
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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -31,19 +43,89 @@ 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"));
}

@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<PackageEntry> entries = customExtractor.extract(file);
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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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));
}
Expand All @@ -58,4 +140,17 @@ private byte[] createZip(String entryName, byte[] content) throws Exception {
}
return baos.toByteArray();
}

private byte[] createZip(Map<String, byte[]> entries) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (Map.Entry<String, byte[]> e : entries.entrySet()) {
ZipEntry entry = new ZipEntry(e.getKey());
zos.putNextEntry(entry);
zos.write(e.getValue());
zos.closeEntry();
}
}
return baos.toByteArray();
}
}
Loading
Loading