fix(skills): support document and folder uploads (#609)

This commit is contained in:
mateaix 2026-09-19 21:49:06 +08:00
parent 5c67af85fc
commit f8a3d0d153
18 changed files with 526 additions and 35 deletions

View File

@ -5,6 +5,13 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ContentDisposition;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.agent.AgentService; import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentSkillBinding; import vip.mate.agent.binding.model.AgentSkillBinding;
@ -42,6 +49,8 @@ import vip.mate.skill.lifecycle.SkillCuratorReportStore;
import vip.mate.skill.lifecycle.SkillLifecycleService; import vip.mate.skill.lifecycle.SkillLifecycleService;
import vip.mate.skill.lifecycle.model.SkillSnapshotEntity; import vip.mate.skill.lifecycle.model.SkillSnapshotEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@ -379,6 +388,7 @@ public class SkillController {
Map<String, Object> item = new LinkedHashMap<>(); Map<String, Object> item = new LinkedHashMap<>();
item.put("path", row.getFilePath()); item.put("path", row.getFilePath());
item.put("size", row.getContentSize()); item.put("size", row.getContentSize());
item.put("binary", row.isBinary());
item.put("sha256", row.getSha256()); item.put("sha256", row.getSha256());
item.put("updateTime", row.getUpdateTime()); item.put("updateTime", row.getUpdateTime());
out.add(item); out.add(item);
@ -404,7 +414,8 @@ public class SkillController {
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("path", row.getFilePath()); body.put("path", row.getFilePath());
body.put("content", row.getContent() == null ? "" : row.getContent()); body.put("binary", row.isBinary());
body.put("content", row.isBinary() ? "" : row.getContent() == null ? "" : row.getContent());
body.put("size", row.getContentSize()); body.put("size", row.getContentSize());
body.put("sha256", row.getSha256()); body.put("sha256", row.getSha256());
body.put("updateTime", row.getUpdateTime()); body.put("updateTime", row.getUpdateTime());
@ -429,6 +440,10 @@ public class SkillController {
if (normalized == null) { if (normalized == null) {
return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'."); return R.fail("Invalid file path — must be under scripts/, references/ or templates/, no '..'.");
} }
SkillFileEntity existing = skillFileService.getFile(id, normalized);
if (existing != null && existing.isBinary()) {
return R.fail("Binary files cannot be edited as text; upload a replacement instead.");
}
String content = body.get("content"); String content = body.get("content");
if (content == null) { if (content == null) {
return R.fail("content is required (use the delete endpoint to remove a file)."); return R.fail("content is required (use the delete endpoint to remove a file).");
@ -479,6 +494,58 @@ public class SkillController {
return R.ok(Map.of("path", normalized, "removed", removed)); return R.ok(Map.of("path", normalized, "removed", removed));
} }
private static final int MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
@PostMapping(value = "/{id}/files/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> uploadBundleFile(@PathVariable Long id,
@RequestPart("file") MultipartFile file, @RequestParam String path,
@RequestParam(defaultValue = "false") boolean overwrite,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) throws IOException {
rejectVirtualSkillMutation(id);
SkillEntity skill = skillService.getSkill(id);
if (skill == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Skill not found");
verifyResourceWorkspace(skill, workspaceId);
if (Boolean.TRUE.equals(skill.getBuiltin())) return R.fail("Builtin skill files are read-only.");
String normalized = normalizeBundlePath(path);
if (normalized == null) return R.fail("Invalid file path: " + path);
if (file.getSize() > MAX_UPLOAD_BYTES) return R.fail("File exceeds the 10 MiB limit.");
if (!overwrite && skillFileService.getFile(id, normalized) != null) {
return R.fail("File already exists; confirm replacement before uploading: " + normalized);
}
byte[] bytes;
try (var input = file.getInputStream()) {
bytes = input.readNBytes(MAX_UPLOAD_BYTES + 1);
}
if (bytes.length > MAX_UPLOAD_BYTES) return R.fail("File exceeds the 10 MiB limit.");
SkillFileEntity row = skillFileService.upsertBytes(id, normalized, bytes);
if (!skillFileSyncer.syncFile(skill, row)) {
return R.fail("File saved, but workspace synchronization failed: " + normalized
+ ". Check directory permissions or conflicting paths, then upload again.");
}
skillRuntimeService.rescanSingle(skill);
return R.ok(Map.of("path", normalized, "size", row.getContentSize(), "binary", row.isBinary()));
}
@GetMapping("/{id}/files/download")
@RequireWorkspaceRole("member")
public ResponseEntity<byte[]> downloadBundleFile(@PathVariable Long id, @RequestParam String path,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
SkillEntity skill = skillService.getSkill(id);
if (skill == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Skill not found");
verifyResourceWorkspace(skill, workspaceId);
String normalized = normalizeBundlePath(path);
if (normalized == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid file path");
SkillFileEntity row = skillFileService.getFile(id, normalized);
if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "File not found");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(normalized.substring(normalized.lastIndexOf('/') + 1), StandardCharsets.UTF_8)
.build().toString())
.header("X-Content-Type-Options", "nosniff")
.body(row.contentBytes());
}
/** /**
* Normalize a bundle-relative path and enforce the same envelope the * Normalize a bundle-relative path and enforce the same envelope the
* store and workspace cache use: forward slashes, must sit under a * store and workspace cache use: forward slashes, must sit under a
@ -491,7 +558,9 @@ public class SkillController {
static String normalizeBundlePath(String path) { static String normalizeBundlePath(String path) {
if (path == null || path.isBlank()) return null; if (path == null || path.isBlank()) return null;
String p = path.strip().replace('\\', '/'); String p = path.strip().replace('\\', '/');
if (p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null; if (p.length() > 512 || p.chars().anyMatch(c -> c < 32 || c == 127)
|| p.contains(":") || p.contains("/./") || p.endsWith("/.")
|| p.startsWith("/") || p.contains("..") || p.contains("//") || p.endsWith("/")) return null;
if (!SkillBundleFiles.isDbEligible(p)) return null; if (!SkillBundleFiles.isDbEligible(p)) return null;
int slash = p.indexOf('/'); int slash = p.indexOf('/');
if (slash == p.length() - 1) return null; if (slash == p.length() - 1) return null;

View File

@ -32,17 +32,30 @@ public class SkillFileEntity {
/** /**
* Path relative to the skill workspace root, always starting with * Path relative to the skill workspace root, always starting with
* {@code scripts/} or {@code references/}. Forward slashes only. * {@code scripts/}, {@code references/} or {@code templates/}. Forward slashes only.
*/ */
private String filePath; private String filePath;
/** UTF-8 text content. Per-file size bounded by ZipSkillFetcher (1MB). */ /** UTF-8 text or base64-encoded attachment bytes, according to contentEncoding. */
private String content; private String content;
/** Length of {@link #content} in bytes — kept so listings can sort/audit without loading the blob. */ /** null on legacy rows means UTF-8. */
private String contentEncoding;
public boolean isBinary() {
return "base64".equals(contentEncoding);
}
public byte[] contentBytes() {
String value = content == null ? "" : content;
return isBinary() ? java.util.Base64.getDecoder().decode(value)
: value.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
/** Length of the original file in bytes — kept so listings can sort/audit without loading the blob. */
private Integer contentSize; private Integer contentSize;
/** SHA-256 of {@link #content}; used by the syncer to skip no-op writes. */ /** SHA-256 of the original bytes; used by the syncer to skip no-op writes. */
private String sha256; private String sha256;
@TableField(fill = FieldFill.INSERT) @TableField(fill = FieldFill.INSERT)

View File

@ -46,9 +46,13 @@ public class SkillFileService {
/** Compute SHA-256 hex of a UTF-8 string (used for idempotent diffs). */ /** Compute SHA-256 hex of a UTF-8 string (used for idempotent diffs). */
public static String sha256Hex(String content) { public static String sha256Hex(String content) {
if (content == null) content = ""; if (content == null) content = "";
return sha256Hex(content.getBytes(StandardCharsets.UTF_8));
}
public static String sha256Hex(byte[] bytes) {
try { try {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(bytes);
StringBuilder sb = new StringBuilder(digest.length * 2); StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b)); for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString(); return sb.toString();
@ -132,6 +136,7 @@ public class SkillFileService {
row.setSkillId(skillId); row.setSkillId(skillId);
row.setFilePath(path); row.setFilePath(path);
row.setContent(content); row.setContent(content);
row.setContentEncoding("utf8");
row.setContentSize(size); row.setContentSize(size);
row.setSha256(hash); row.setSha256(hash);
row.setCreateTime(now); row.setCreateTime(now);
@ -140,6 +145,7 @@ public class SkillFileService {
written++; written++;
} else if (!hash.equals(prior.getSha256())) { } else if (!hash.equals(prior.getSha256())) {
prior.setContent(content); prior.setContent(content);
prior.setContentEncoding("utf8");
prior.setContentSize(size); prior.setContentSize(size);
prior.setSha256(hash); prior.setSha256(hash);
prior.setUpdateTime(now); prior.setUpdateTime(now);
@ -193,32 +199,40 @@ public class SkillFileService {
*/ */
@Transactional @Transactional
public SkillFileEntity upsertFile(Long skillId, String filePath, String content) { public SkillFileEntity upsertFile(Long skillId, String filePath, String content) {
String safeContent = content == null ? "" : content; return upsertBytes(skillId, filePath,
String hash = sha256Hex(safeContent); (content == null ? "" : content).getBytes(StandardCharsets.UTF_8));
}
/** Preserve arbitrary bytes; only strictly valid UTF-8 without control bytes is editable text. */
@Transactional
public SkillFileEntity upsertBytes(Long skillId, String filePath, byte[] bytes) {
String text = null;
try {
text = StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes)).toString();
if (text.codePoints().anyMatch(c -> c < 32 && c != '\n' && c != '\r' && c != '\t')) text = null;
} catch (java.nio.charset.CharacterCodingException binary) {
// Keep the original bytes below.
}
String encoding = text == null ? "base64" : "utf8";
String content = text == null ? java.util.Base64.getEncoder().encodeToString(bytes) : text;
String hash = sha256Hex(bytes);
SkillFileEntity row = getFile(skillId, filePath);
boolean insert = row == null;
if (!insert && hash.equals(row.getSha256())) return row;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (insert) {
SkillFileEntity existing = getFile(skillId, filePath); row = new SkillFileEntity();
if (existing != null) {
if (hash.equals(existing.getSha256())) {
return existing;
}
existing.setContent(safeContent);
existing.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
existing.setSha256(hash);
existing.setUpdateTime(now);
mapper.updateById(existing);
return existing;
}
SkillFileEntity row = new SkillFileEntity();
row.setSkillId(skillId); row.setSkillId(skillId);
row.setFilePath(filePath); row.setFilePath(filePath);
row.setContent(safeContent);
row.setContentSize(safeContent.getBytes(StandardCharsets.UTF_8).length);
row.setSha256(hash);
row.setCreateTime(now); row.setCreateTime(now);
}
row.setContent(content);
row.setContentEncoding(encoding);
row.setContentSize(bytes.length);
row.setSha256(hash);
row.setUpdateTime(now); row.setUpdateTime(now);
mapper.insert(row); if (insert) mapper.insert(row);
else mapper.updateById(row);
return row; return row;
} }

View File

@ -161,6 +161,12 @@ public class SkillFileSyncer {
return ingested.size(); return ingested.size();
} }
/** Materialize only the uploaded row; folder uploads must not re-read the full bundle per file. */
public boolean syncFile(SkillEntity skill, SkillFileEntity row) {
Path workspace = workspaceManager.resolveConventionPath(skill.getName(), skill.getWorkspaceId());
return materializeOne(workspace, row) != MaterializeOutcome.SKIPPED;
}
private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED } private enum MaterializeOutcome { WROTE, CURRENT, SKIPPED }
private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) { private MaterializeOutcome materializeOne(Path workspaceDir, SkillFileEntity row) {
@ -183,17 +189,21 @@ public class SkillFileSyncer {
} }
try { try {
String content = row.getContent() == null ? "" : row.getContent(); // Do not follow links created by a skill script outside its workspace.
for (Path part = target; part != null && part.startsWith(workspaceDir); part = part.getParent()) {
if (Files.isSymbolicLink(part)) return MaterializeOutcome.SKIPPED;
}
byte[] content = row.contentBytes();
if (Files.exists(target)) { if (Files.exists(target)) {
String onDisk = Files.readString(target, StandardCharsets.UTF_8); byte[] onDisk = Files.readAllBytes(target);
if (SkillFileService.sha256Hex(onDisk).equals(row.getSha256())) { if (SkillFileService.sha256Hex(onDisk).equals(row.getSha256())) {
return MaterializeOutcome.CURRENT; return MaterializeOutcome.CURRENT;
} }
} }
Files.createDirectories(target.getParent()); Files.createDirectories(target.getParent());
Files.writeString(target, content, StandardCharsets.UTF_8); Files.write(target, content);
return MaterializeOutcome.WROTE; return MaterializeOutcome.WROTE;
} catch (IOException e) { } catch (IOException | IllegalArgumentException e) {
log.warn("Failed to materialize skill_file {} → {}: {}", row.getId(), target, e.getMessage()); log.warn("Failed to materialize skill_file {} → {}: {}", row.getId(), target, e.getMessage());
return MaterializeOutcome.SKIPPED; return MaterializeOutcome.SKIPPED;
} }

View File

@ -0,0 +1,2 @@
-- Preserve existing UTF-8 rows; binary attachments use base64 in the canonical content column.
ALTER TABLE mate_skill_file ADD COLUMN content_encoding VARCHAR(16) DEFAULT 'utf8' NOT NULL;

View File

@ -0,0 +1,2 @@
-- Preserve existing UTF-8 rows; binary attachments use base64 in the canonical content column.
ALTER TABLE mate_skill_file ADD COLUMN content_encoding VARCHAR(16) DEFAULT 'utf8' NOT NULL;

View File

@ -0,0 +1,2 @@
-- Preserve existing UTF-8 rows; binary attachments use base64 in the canonical content column.
ALTER TABLE mate_skill_file ADD COLUMN content_encoding VARCHAR(16) DEFAULT 'utf8' NOT NULL;

View File

@ -188,4 +188,63 @@ class SkillControllerBundleFilesTest {
assertThat(resp.getMsg()).contains("read-only"); assertThat(resp.getMsg()).contains("read-only");
verify(fileService, never()).deleteFile(any(), any()); verify(fileService, never()).deleteFile(any(), any());
} }
@Test
void uploadsAndDownloadsOriginalDocumentBytes() throws Exception {
when(skillService.getSkill(SID)).thenReturn(skill(false));
byte[] bytes = {80, 75, 3, 4, 0, (byte) 255};
var file = new org.springframework.mock.web.MockMultipartFile("file", "报告.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", bytes);
var row = row("references/资料/报告.docx", java.util.Base64.getEncoder().encodeToString(bytes));
row.setContentEncoding("base64");
row.setContentSize(bytes.length);
when(fileService.upsertBytes(SID, row.getFilePath(), bytes)).thenReturn(row);
when(fileSyncer.syncFile(any(SkillEntity.class), any(SkillFileEntity.class))).thenReturn(true);
var result = controller.uploadBundleFile(SID, file, row.getFilePath(), false, null);
assertThat(result.getData()).containsEntry("binary", true).containsEntry("size", bytes.length);
verify(fileSyncer).syncFile(any(SkillEntity.class), any(SkillFileEntity.class));
when(fileService.getFile(SID, row.getFilePath())).thenReturn(row);
assertThat(controller.downloadBundleFile(SID, row.getFilePath(), null).getBody()).isEqualTo(bytes);
assertThat(controller.getBundleFileContent(SID, row.getFilePath(), null).getData())
.containsEntry("binary", true).containsEntry("content", "");
assertThat(controller.putBundleFileContent(SID,
Map.of("path", row.getFilePath(), "content", "corrupt"), null).getMsg()).contains("Binary");
}
@Test
void uploadRejectsUnsafePathsReadonlyAndUnconfirmedReplacement() throws Exception {
var file = new org.springframework.mock.web.MockMultipartFile("file", "a.txt", "text/plain", new byte[]{1});
when(skillService.getSkill(SID)).thenReturn(skill(false));
for (String path : List.of("references/../bad", "references/./bad", "references/a\u0000b", "/tmp/a")) {
assertThat(controller.uploadBundleFile(SID, file, path, false, null).getMsg()).contains("Invalid");
}
when(fileService.getFile(SID, "references/a.txt")).thenReturn(row("references/a.txt", "old"));
assertThat(controller.uploadBundleFile(SID, file, "references/a.txt", false, null).getMsg()).contains("already exists");
when(skillService.getSkill(SID)).thenReturn(skill(true));
assertThat(controller.uploadBundleFile(SID, file, "references/a.txt", true, null).getMsg()).contains("read-only");
verify(fileService, never()).upsertBytes(any(), any(), any());
}
@Test
void uploadRejectsOversizedFilesBeforeReading() throws Exception {
when(skillService.getSkill(SID)).thenReturn(skill(false));
var file = mock(org.springframework.web.multipart.MultipartFile.class);
when(file.getSize()).thenReturn(10L * 1024 * 1024 + 1);
assertThat(controller.uploadBundleFile(SID, file, "references/a.docx", false, null).getMsg()).contains("10 MiB");
verify(file, never()).getInputStream();
}
@Test
void attachmentEndpointsRejectOtherWorkspacesBeforeReadingBytes() {
when(skillService.getSkill(SID)).thenReturn(skill(false));
var file = mock(org.springframework.web.multipart.MultipartFile.class);
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
controller.uploadBundleFile(SID, file, "references/a.docx", false, 2L))
.isInstanceOf(vip.mate.exception.MateClawException.class);
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
controller.downloadBundleFile(SID, "references/a.docx", 2L))
.isInstanceOf(vip.mate.exception.MateClawException.class);
verify(fileService, never()).getFile(any(), any());
verify(fileService, never()).upsertBytes(any(), any(), any());
}
} }

View File

@ -0,0 +1,44 @@
package vip.mate.skill.service;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SkillFileEncodingMigrationTest {
@Test
void migrationPreservesLegacyTextAndStoresBinaryContent() throws Exception {
for (String dialect : List.of("h2", "mysql", "kingbase")) {
String migration = Files.readString(Path.of("src/main/resources/db/migration", dialect,
"V202__skill_file_encoding.sql"));
// The added column uses portable SQL. Exercise it on populated tables,
// including dialect modes; native MySQL/Kingbase still require deployment verification.
String mode = dialect.equals("kingbase") ? "PostgreSQL" : "MySQL";
try (var connection = DriverManager.getConnection("jdbc:h2:mem:encoding_" + dialect + ";MODE=" + mode);
var statement = connection.createStatement()) {
statement.execute("CREATE TABLE mate_skill_file (id BIGINT PRIMARY KEY, content TEXT)");
statement.execute("INSERT INTO mate_skill_file VALUES (1, '制度说明')");
statement.execute(migration);
try (var result = statement.executeQuery("SELECT content, content_encoding FROM mate_skill_file WHERE id=1")) {
assertTrue(result.next());
assertEquals("制度说明", result.getString(1));
assertEquals("utf8", result.getString(2));
}
byte[] bytes = {80, 75, 0, (byte) 255};
try (var insert = connection.prepareStatement("INSERT INTO mate_skill_file VALUES (2, ?, 'base64')")) {
insert.setString(1, Base64.getEncoder().encodeToString(bytes));
insert.executeUpdate();
}
try (var result = statement.executeQuery("SELECT content FROM mate_skill_file WHERE id=2")) {
assertTrue(result.next());
assertArrayEquals(bytes, Base64.getDecoder().decode(result.getString(1)));
}
}
}
}
}

View File

@ -114,4 +114,36 @@ class SkillFileServiceTest {
e.setSha256(SkillFileService.sha256Hex(content)); e.setSha256(SkillFileService.sha256Hex(content));
return e; return e;
} }
@Test
void binaryUploadPreservesBytesSizeAndHash() {
byte[] bytes = {80, 75, 3, 4, 0, (byte) 255, (byte) 128};
SkillFileEntity row = service.upsertBytes(42L, "templates/报告.xlsx", bytes);
assertTrue(row.isBinary());
assertArrayEquals(bytes, row.contentBytes());
assertEquals(bytes.length, row.getContentSize());
assertEquals(SkillFileService.sha256Hex(bytes), row.getSha256());
verify(mapper).insert(row);
}
@Test
void uploadedUtf8RemainsEditableAndLegacyRowsDecodeUnchanged() {
String text = "制度说明\n中文";
SkillFileEntity row = service.upsertBytes(42L, "references/制度.md",
text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertFalse(row.isBinary());
assertEquals(text, row.getContent());
row.setContentEncoding(null);
assertArrayEquals(text.getBytes(java.nio.charset.StandardCharsets.UTF_8), row.contentBytes());
}
@Test
void replacingBinaryWithTextClearsEncoding() {
SkillFileEntity prior = newRow(1L, "references/file", "AA==");
prior.setContentEncoding("base64");
when(mapper.selectOne(any())).thenReturn(prior);
SkillFileEntity row = service.upsertBytes(42L, "references/file", "hello".getBytes());
assertFalse(row.isBinary());
assertArrayEquals("hello".getBytes(), row.contentBytes());
}
} }

View File

@ -167,4 +167,35 @@ class SkillFileSyncerTest {
e.setSha256(SkillFileService.sha256Hex(content)); e.setSha256(SkillFileService.sha256Hex(content));
return e; return e;
} }
@Test
void binaryAttachmentSurvivesRestoreAndStaleCacheReplacement() throws IOException {
SkillEntity skill = newSkill(10L, "demo");
byte[] bytes = {80, 75, 3, 4, 0, (byte) 255};
SkillFileEntity row = newRow(1L, 10L, "templates/报告.xlsx", "");
row.setContent(java.util.Base64.getEncoder().encodeToString(bytes));
row.setContentEncoding("base64");
row.setSha256(SkillFileService.sha256Hex(bytes));
when(mapper.selectList(any())).thenReturn(List.of(row));
Path target = tmp.resolve("1/demo/templates/报告.xlsx");
assertEquals(1, syncer.syncOne(skill).filesMaterialized());
assertArrayEquals(bytes, Files.readAllBytes(target));
assertEquals(1, syncer.syncOne(skill).filesAlreadyCurrent());
Files.write(target, new byte[]{(byte) 255, 0, 1});
assertEquals(1, syncer.syncOne(skill).filesMaterialized());
assertArrayEquals(bytes, Files.readAllBytes(target));
}
@Test
void refusesSymlinkInsideSkillWorkspace() throws IOException {
SkillEntity skill = newSkill(10L, "demo");
Path workspace = tmp.resolve("1/demo");
Path outside = tmp.resolve("outside");
Files.createDirectories(workspace);
Files.createDirectories(outside);
Files.createSymbolicLink(workspace.resolve("references"), outside);
when(mapper.selectList(any())).thenReturn(List.of(newRow(1L, 10L, "references/a.txt", "secret")));
assertEquals(0, syncer.syncOne(skill).filesMaterialized());
assertFalse(Files.exists(outside.resolve("a.txt")));
}
} }

View File

@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { http, skillApi } from '@/api/index'
describe('skill attachment API', () => {
afterEach(() => vi.restoreAllMocks())
it('sends the original file and relative path without coercing large skill IDs', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({} as never)
const file = new File([new Uint8Array([80, 75, 0, 255])], '模板.xlsx')
await skillApi.uploadFile('9007199254740993', file, 'templates/部门/模板.xlsx')
const [url, body] = post.mock.calls[0]
expect(url).toBe('/skills/9007199254740993/files/upload')
const form = body as FormData
expect(form.get('path')).toBe('templates/部门/模板.xlsx')
expect(form.get('overwrite')).toBe('false')
expect(await (form.get('file') as File).arrayBuffer()).toEqual(await file.arrayBuffer())
await skillApi.uploadFile('9007199254740993', file, 'templates/部门/模板.xlsx', true)
expect((post.mock.calls[1][1] as FormData).get('overwrite')).toBe('true')
})
it('downloads authenticated bytes through the shared HTTP client', async () => {
const blob = new Blob([new Uint8Array([0, 255])])
const get = vi.spyOn(http, 'get').mockResolvedValue(blob as never)
expect(await skillApi.downloadFile('9007199254740993', 'references/a.docx')).toBe(blob)
expect(get).toHaveBeenCalledWith('/skills/9007199254740993/files/download', {
params: { path: 'references/a.docx' }, responseType: 'blob',
})
})
})

View File

@ -275,6 +275,15 @@ export const skillApi = {
* re-resolve the skill. * re-resolve the skill.
*/ */
listFiles: (id: string | number) => http.get(`/skills/${id}/files`), listFiles: (id: string | number) => http.get(`/skills/${id}/files`),
uploadFile: (id: string | number, file: File, path: string, overwrite = false) => {
const form = new FormData()
form.append('file', file)
form.append('path', path)
form.append('overwrite', String(overwrite))
return http.post(`/skills/${id}/files/upload`, form, { timeout: 120000 })
},
downloadFile: (id: string | number, path: string): Promise<Blob> =>
http.get(`/skills/${id}/files/download`, { params: { path }, responseType: 'blob' }) as unknown as Promise<Blob>,
getFileContent: (id: string | number, path: string) => getFileContent: (id: string | number, path: string) =>
http.get(`/skills/${id}/files/content`, { params: { path } }), http.get(`/skills/${id}/files/content`, { params: { path } }),
saveFileContent: (id: string | number, path: string, content: string) => saveFileContent: (id: string | number, path: string, content: string) =>

View File

@ -4305,6 +4305,20 @@ export default {
fileEdit: 'Edit', fileEdit: 'Edit',
fileDelete: 'Delete', fileDelete: 'Delete',
fileDeleteConfirm: 'Delete "{path}"? This removes both the database record and the workspace file. Cannot be undone.', fileDeleteConfirm: 'Delete "{path}"? This removes both the database record and the workspace file. Cannot be undone.',
uploadFiles: 'Upload files',
uploadFolder: 'Upload folder',
uploadDestination: 'Destination',
uploadHint: 'Supports Word, Excel and other files. Folder structure is preserved. Up to 10 MiB per file, 100 files and 50 MiB per batch. Empty folders are not uploaded.',
uploadOverwrite: 'Replace these existing files? {paths}',
uploadComplete: 'Uploaded {count} files',
uploadFailed: 'Uploaded {count} files; remaining uploads did not finish: {error}',
uploadErrors: {
batchLimit: 'Each batch is limited to 100 files and 50 MiB.',
fileLimit: 'Each file must be at most 10 MiB.',
invalidPath: 'File paths are invalid, duplicated or too long.',
},
fileDownload: 'Download',
binaryFileHint: 'This file is preserved in its original format. Download to view it, or upload a replacement to change it.',
fileSaved: 'File saved', fileSaved: 'File saved',
fileDeleted: 'File deleted', fileDeleted: 'File deleted',
fileLoadFailed: 'Failed to read file', fileLoadFailed: 'Failed to read file',

View File

@ -4397,6 +4397,20 @@ export default {
fileEdit: '编辑', fileEdit: '编辑',
fileDelete: '删除', fileDelete: '删除',
fileDeleteConfirm: '删除文件 "{path}"?此操作同时移除数据库记录和工作区文件,不可撤销。', fileDeleteConfirm: '删除文件 "{path}"?此操作同时移除数据库记录和工作区文件,不可撤销。',
uploadFiles: '上传文件',
uploadFolder: '上传文件夹',
uploadDestination: '保存目录',
uploadHint: '支持 Word、Excel 等文件,文件夹会保留目录层级。单个文件不超过 10 MiB,每批最多 100 个文件、合计 50 MiB;空文件夹不会上传。',
uploadOverwrite: '以下同名文件将被替换,是否继续?{paths}',
uploadComplete: '已上传 {count} 个文件',
uploadFailed: '已上传 {count} 个文件,其余未完成:{error}',
uploadErrors: {
batchLimit: '每批最多上传 100 个文件,合计不超过 50 MiB。',
fileLimit: '单个文件不能超过 10 MiB。',
invalidPath: '文件路径无效、重复或过长,请检查文件名和目录。',
},
fileDownload: '下载',
binaryFileHint: '此文件保留原始格式,可下载查看;如需修改,请上传替换文件。',
fileSaved: '文件已保存', fileSaved: '文件已保存',
fileDeleted: '文件已删除', fileDeleted: '文件已删除',
fileLoadFailed: '读取文件失败', fileLoadFailed: '读取文件失败',

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { planSkillFileUploads } from '../skillFileUpload'
function file(name: string, relative = '', size = 1): File {
const result = new File(['x'], name)
Object.defineProperty(result, 'webkitRelativePath', { value: relative })
Object.defineProperty(result, 'size', { value: size })
return result
}
describe('skill file uploads', () => {
it('keeps Unicode folder structure and original files under the chosen bucket', () => {
const document = file('报告.docx', '制度/部门/报告.docx')
expect(planSkillFileUploads([document], 'references')).toEqual([
{ file: document, path: 'references/制度/部门/报告.docx' },
])
expect(planSkillFileUploads([file('report.xlsx')], 'templates')[0].path).toBe('templates/report.xlsx')
})
it('rejects traversal, absolute paths, duplicate paths and control characters', () => {
for (const path of ['../a', '/tmp/a', 'x/./a', 'x//a', 'x\\a', 'x\0a', 'C:/a']) {
expect(() => planSkillFileUploads([file('a', path)], 'references')).toThrow('invalidPath')
}
expect(() => planSkillFileUploads([file('a'), file('a')], 'references')).toThrow('invalidPath')
})
it('bounds individual files and batches before sending any requests', () => {
expect(() => planSkillFileUploads([file('a', '', 10 * 1024 * 1024 + 1)], 'references')).toThrow('fileLimit')
expect(() => planSkillFileUploads(Array.from({ length: 101 }, (_, i) => file(String(i))), 'references')).toThrow('batchLimit')
expect(() => planSkillFileUploads(Array.from({ length: 6 }, (_, i) => file(String(i), '', 10 * 1024 * 1024)), 'references')).toThrow('batchLimit')
})
})

View File

@ -0,0 +1,20 @@
export type SkillFileBucket = 'references' | 'templates' | 'scripts'
/** Keep the selected folder and all descendants under the chosen skill bucket. */
export function planSkillFileUploads(files: File[], bucket: SkillFileBucket) {
if (files.length > 100 || files.reduce((n, file) => n + file.size, 0) > 50 * 1024 * 1024) {
throw new Error('batchLimit')
}
const paths = new Set<string>()
return files.map(file => {
if (file.size > 10 * 1024 * 1024) throw new Error('fileLimit')
const relative = file.webkitRelativePath || file.name
const path = `${bucket}/${relative}`
if (path.length > 512 || relative.includes('..') || /[\\:\x00-\x1f\x7f]/.test(relative)
|| relative.split('/').some(part => !part || part === '.') || paths.has(path)) {
throw new Error('invalidPath')
}
paths.add(path)
return { file, path }
})
}

View File

@ -481,6 +481,7 @@
v-if="canEditFiles && !creatingFile" v-if="canEditFiles && !creatingFile"
class="detail-edit-btn detail-edit-primary" class="detail-edit-btn detail-edit-primary"
@click="startCreateFile" @click="startCreateFile"
:disabled="uploadingFiles"
> >
+ {{ t('skills.detail.fileNew') }} + {{ t('skills.detail.fileNew') }}
</button> </button>
@ -488,6 +489,24 @@
<p class="detail-hint">{{ t('skills.detail.filesHint') }}</p> <p class="detail-hint">{{ t('skills.detail.filesHint') }}</p>
<p v-if="isBuiltinDetail" class="detail-hint">{{ t('skills.detail.builtinFilesReadonly') }}</p> <p v-if="isBuiltinDetail" class="detail-hint">{{ t('skills.detail.builtinFilesReadonly') }}</p>
<fieldset v-if="canEditFiles" class="skill-file-upload" :disabled="uploadingFiles || savingFile || editingFile || creatingFile">
<label>{{ t('skills.detail.uploadDestination') }}
<select v-model="uploadBucket" :aria-label="t('skills.detail.uploadDestination')">
<option value="references">references/</option>
<option value="templates">templates/</option>
<option value="scripts">scripts/</option>
</select>
</label>
<label>{{ t('skills.detail.uploadFiles') }}
<input type="file" multiple :aria-label="t('skills.detail.uploadFiles')" @change="uploadSkillFiles" />
</label>
<label>{{ t('skills.detail.uploadFolder') }}
<input type="file" multiple webkitdirectory :aria-label="t('skills.detail.uploadFolder')" @change="uploadSkillFiles" />
</label>
<p class="detail-hint">{{ t('skills.detail.uploadHint') }}</p>
</fieldset>
<p v-if="uploadingFiles" role="status">{{ uploadProgress || t('common.loading') }}</p>
<!-- New-file form --> <!-- New-file form -->
<div v-if="creatingFile" class="skill-file-create"> <div v-if="creatingFile" class="skill-file-create">
<input <input
@ -536,10 +555,11 @@
<h4 class="detail-block-title"><code>{{ activeFile.path }}</code></h4> <h4 class="detail-block-title"><code>{{ activeFile.path }}</code></h4>
<div class="edit-actions"> <div class="edit-actions">
<template v-if="!editingFile"> <template v-if="!editingFile">
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-primary" @click="startEditFile"> <button class="detail-edit-btn" @click="downloadActiveFile">{{ t('skills.detail.fileDownload') }}</button>
<button v-if="canEditFiles && !activeFile.binary" :disabled="uploadingFiles" class="detail-edit-btn detail-edit-primary" @click="startEditFile">
{{ t('skills.detail.fileEdit') }} {{ t('skills.detail.fileEdit') }}
</button> </button>
<button v-if="canEditFiles" class="detail-edit-btn detail-edit-cancel" @click="removeActiveFile" :disabled="savingFile"> <button v-if="canEditFiles" class="detail-edit-btn detail-edit-cancel" @click="removeActiveFile" :disabled="savingFile || uploadingFiles">
{{ t('skills.detail.fileDelete') }} {{ t('skills.detail.fileDelete') }}
</button> </button>
</template> </template>
@ -554,6 +574,7 @@
</div> </div>
</div> </div>
<p v-if="fileContentLoading" class="detail-empty">{{ t('common.loading') }}</p> <p v-if="fileContentLoading" class="detail-empty">{{ t('common.loading') }}</p>
<p v-else-if="activeFile.binary" class="detail-hint">{{ t('skills.detail.binaryFileHint') }}</p>
<pre v-else-if="!editingFile" class="detail-pre">{{ activeFileContent }}</pre> <pre v-else-if="!editingFile" class="detail-pre">{{ activeFileContent }}</pre>
<textarea <textarea
v-else v-else
@ -798,6 +819,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { planSkillFileUploads, type SkillFileBucket } from '@/utils/skillFileUpload'
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast' import { mcToast } from '@/composables/useMcToast'
@ -911,11 +933,79 @@ const editBodyForm = ref<{ skillContent: string; sourceCode: string }>({
interface SkillBundleFile { interface SkillBundleFile {
path: string path: string
binary?: boolean
size?: number size?: number
sha256?: string sha256?: string
updateTime?: string updateTime?: string
} }
const uploadBucket = ref<SkillFileBucket>('references')
const uploadingFiles = ref(false)
const uploadProgress = ref('')
async function uploadSkillFiles(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!files.length || !detailSkill.value || uploadingFiles.value) return
const skillId = detailSkill.value.id
let plans
try {
plans = planSkillFileUploads(files, uploadBucket.value)
} catch (error) {
mcToast.error(t(`skills.detail.uploadErrors.${(error as Error).message}`))
return
}
uploadingFiles.value = true
let uploaded = 0
try {
const response: any = await skillApi.listFiles(skillId)
const existing = new Set<string>((response?.data ?? []).map((f: SkillBundleFile) => f.path))
const conflicts = plans.filter(item => existing.has(item.path))
if (conflicts.length && !await mcConfirm({
title: t('skills.detail.uploadFiles'),
message: t('skills.detail.uploadOverwrite', { paths: conflicts.map(item => item.path).join('、') }),
tone: 'danger',
})) return
for (const item of plans) {
uploadProgress.value = `${uploaded + 1}/${plans.length}: ${item.path}`
await skillApi.uploadFile(skillId, item.file, item.path, existing.has(item.path))
uploaded++
}
mcToast.success(t('skills.detail.uploadComplete', { count: uploaded }))
} catch (error: any) {
mcToast.error(t('skills.detail.uploadFailed', { count: uploaded, error: error?.message || String(error) }))
} finally {
uploadingFiles.value = false
uploadProgress.value = ''
if (detailSkill.value?.id === skillId) {
activeFile.value = null
editingFile.value = false
await loadDetailFiles()
}
}
}
async function downloadActiveFile() {
if (!detailSkill.value || !activeFile.value) return
const path = activeFile.value.path
try {
const blob = await skillApi.downloadFile(detailSkill.value.id, path)
if (blob.type.includes('application/json')) {
const error = JSON.parse(await blob.text())
throw new Error(error.msg || t('skills.detail.fileLoadFailed'))
}
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = path.split('/').pop() || 'download'
link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (error: any) {
mcToast.error(error?.message || t('skills.detail.fileLoadFailed'))
}
}
const detailFiles = ref<SkillBundleFile[]>([]) const detailFiles = ref<SkillBundleFile[]>([])
const detailFilesLoading = ref(false) const detailFilesLoading = ref(false)
const activeFile = ref<SkillBundleFile | null>(null) const activeFile = ref<SkillBundleFile | null>(null)
@ -961,6 +1051,7 @@ async function openFile(file: SkillBundleFile) {
try { try {
const res: any = await skillApi.getFileContent(detailSkill.value.id, file.path) const res: any = await skillApi.getFileContent(detailSkill.value.id, file.path)
activeFileContent.value = res?.data?.content ?? '' activeFileContent.value = res?.data?.content ?? ''
file.binary = Boolean(res?.data?.binary)
} catch (e: any) { } catch (e: any) {
activeFileContent.value = '' activeFileContent.value = ''
mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileLoadFailed')) mcToast.error(typeof e === 'string' ? e : e?.message || t('skills.detail.fileLoadFailed'))
@ -970,6 +1061,7 @@ async function openFile(file: SkillBundleFile) {
} }
function startEditFile() { function startEditFile() {
if (activeFile.value?.binary) return
fileEditContent.value = activeFileContent.value fileEditContent.value = activeFileContent.value
editingFile.value = true editingFile.value = true
} }
@ -2012,6 +2104,11 @@ function getSkillTypeLabel(type: string) {
</script> </script>
<style scoped> <style scoped>
.skill-file-upload { display: flex; flex-wrap: wrap; gap: 12px; border: 1px solid var(--mc-border, #ddd); border-radius: 8px; padding: 12px; margin: 12px 0; }
.skill-file-upload label { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.skill-file-upload input { max-width: 100%; }
.skill-file-upload p { flex-basis: 100%; margin: 0; }
.skills-page { gap: 18px; } .skills-page { gap: 18px; }
.header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .header-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; box-shadow: var(--mc-shadow-soft); } .btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; box-shadow: var(--mc-shadow-soft); }