From 594880bd64f1a1ba47d6f1a7ff62f69bbf9feb06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Wed, 17 Jun 2026 21:30:13 +0800 Subject: [PATCH] feat(webchat): support inbound file upload and outbound download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebChat had no file support: the /stream body carried only text, and agent-produced files had no visitor-reachable download path (the JWT /chat/files endpoint is unreachable for API-key visitors). Add webchat-authenticated file transfer, reusing MessageContentPart + the existing upload dir + agent multimodal injection: - WebChatFileService: validate (size cap, extension whitelist, filename sanitize), store under the conversation's upload dir, stage by opaque fileId, traversal-safe resolve. Untrusted-uploader hardening lives here. - POST /upload (multipart) and GET /files, both authed by API key + visitor token with a server-derived conversationId (never client paths). Downloads send non-images as attachment + X-Content-Type-Options:nosniff. - /stream gains attachmentIds; the server resolves each id from the staging registry (client metadata is never trusted), builds parts, and persists them on the user message so the agent's multimodal/file tools pick them up from history — same path as the JWT web chat. - Strip server-side file paths from the visitor-facing message view (listMessageViewsExternal + includePath flag) so the filesystem layout is not disclosed. Refs matevip/mateclaw#342 --- .../channel/webchat/WebChatController.java | 172 +++++++++++++- .../channel/webchat/WebChatFileService.java | 219 ++++++++++++++++++ .../conversation/ConversationService.java | 43 +++- .../webchat/WebChatFileServiceTest.java | 102 ++++++++ .../ConversationServiceExternalViewTest.java | 84 +++++++ 5 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 76b69cf1..bf2f836b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -7,15 +7,24 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Base64; import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.agent.AgentService; @@ -63,6 +72,7 @@ public class WebChatController { private final ObjectMapper objectMapper; private final ConversationCompletionPublisher completionPublisher; private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; + private final WebChatFileService fileService; /** * Server-only secret used to sign per-visitor tokens. Reuses the JWT secret so no extra @@ -158,8 +168,10 @@ public class WebChatController { Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L; var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId); - // 保存用户消息 - conversationService.saveMessage(conversationId, "user", message, List.of()); + // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, + // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 + List userParts = buildUserParts(conversationId, message, request.getAttachmentIds()); + conversationService.saveMessage(conversationId, "user", message, userParts); // 初始化 SSE 流跟踪 streamTracker.register(conversationId); @@ -337,7 +349,8 @@ public class WebChatController { if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } - return R.ok(conversationService.listMessageViews(conversationId)); + // External view: strip server-side file paths before handing messages to the visitor. + return R.ok(conversationService.listMessageViewsExternal(conversationId)); } /** @@ -371,6 +384,155 @@ public class WebChatController { return R.ok(); } + /** + * 上传文件(入站)。访客先上传拿到 fileId,再在 /stream 的 attachmentIds 中引用。 + *

鉴权同会话接口:API Key + visitor token;conversationId 服务端派生。 + */ + @Operation(summary = "WebChat 上传文件") + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R> uploadFile( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestPart("file") MultipartFile file) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return R.fail(401, "Invalid or missing visitor token"); + } + // Upload requires an established visitor identity (the token is bound to it); + // unlike /stream we never mint a fresh visitorId here. + String vid; + String sid; + try { + if (visitorId == null || visitorId.trim().isEmpty()) { + return R.fail(400, "visitorId is required"); + } + vid = normalizeVisitorId(visitorId); + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + String conversationId = deriveConversationId(apiKey, vid, sid); + try { + WebChatFileService.StagedFile stored = fileService.store(conversationId, file); + return R.ok(Map.of( + "fileId", stored.storedName(), + "fileName", stored.originalName(), + "contentType", stored.contentType() != null ? stored.contentType() : "application/octet-stream", + "size", stored.size() + )); + } catch (WebChatFileService.UploadRejectedException ex) { + return R.fail(400, ex.getMessage()); + } catch (IOException ex) { + log.error("[WebChat] Upload failed conv={}: {}", conversationId, ex.getMessage()); + return R.fail(500, "Upload failed"); + } + } + + /** + * 下载文件(出站)。serves both visitor-uploaded files and agent-produced files + * written under the conversation dir. 鉴权同上,路径在服务端派生目录内防穿越。 + */ + @Operation(summary = "WebChat 下载文件") + @GetMapping("/files") + public ResponseEntity downloadFile( + @RequestHeader("X-MC-Key") String apiKey, + @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, + @RequestParam String visitorId, + @RequestParam(required = false) String sessionId, + @RequestParam String storedName) { + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return ResponseEntity.status(401).build(); + } + if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { + return ResponseEntity.status(401).build(); + } + String sid; + try { + sid = normalizeSessionId(sessionId); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().build(); + } + String conversationId = deriveConversationId(apiKey, visitorId, sid); + if (!ownsConversation(conversationId, visitorId)) { + return ResponseEntity.status(404).build(); + } + Path file = fileService.resolve(conversationId, storedName).orElse(null); + if (file == null) { + return ResponseEntity.notFound().build(); + } + + String contentType; + try { + contentType = Files.probeContentType(file); + } catch (IOException e) { + contentType = null; + } + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + if (contentType != null) { + try { + mediaType = MediaType.parseMediaType(contentType); + } catch (Exception ignored) { + // fall back to octet-stream + } + } + // Only inline images; everything else downloads as an attachment. nosniff + // stops the browser from re-interpreting the bytes as active content. + boolean inlineImage = contentType != null && contentType.startsWith("image/"); + String encodedName = URLEncoder.encode(file.getFileName().toString(), StandardCharsets.UTF_8) + .replace("+", "%20"); + return ResponseEntity.ok() + .contentType(mediaType) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, + (inlineImage ? "inline" : "attachment") + "; filename*=UTF-8''" + encodedName) + .body(new FileSystemResource(file)); + } + + /** + * Build the user message's content parts: a text part for the message plus a + * file/media part for each referenced attachment. Attachment metadata is + * resolved server-side from the staging registry (the client only sends opaque + * ids); an id that is unknown, expired, or belongs to another conversation is + * silently dropped. + */ + private List buildUserParts(String conversationId, String message, + List attachmentIds) { + List parts = new ArrayList<>(); + if (message != null && !message.isBlank()) { + MessageContentPart text = new MessageContentPart(); + text.setType("text"); + text.setText(message); + parts.add(text); + } + if (attachmentIds != null) { + for (String fileId : attachmentIds) { + fileService.consume(conversationId, fileId).ifPresent(sf -> { + MessageContentPart p = new MessageContentPart(); + p.setType(WebChatFileService.partTypeFor(sf.contentType())); + p.setFileName(sf.originalName()); + p.setContentType(sf.contentType()); + p.setStoredName(sf.storedName()); + p.setFileSize(sf.size()); + // Relative download ref (caller adds auth headers + visitorId/sessionId). + p.setFileUrl("/api/v1/channels/webchat/files?storedName=" + + URLEncoder.encode(sf.storedName(), StandardCharsets.UTF_8)); + // Server path lets the agent's file tools read the upload; stripped from + // the external message view (listMessageViewsExternal). + fileService.resolve(conversationId, sf.storedName()) + .ifPresent(path -> p.setPath(path.toString())); + parts.add(p); + }); + } + } + return parts; + } + // ==================== 内部方法 ==================== private static final Pattern SESSION_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{1,64}"); @@ -566,6 +728,10 @@ public class WebChatController { /** Optional: open a distinct conversation thread for the same visitor. * Composed into the server-derived conversationId; never used as a raw conversationId. */ private String sessionId; + /** Optional: ids returned by POST /upload, referencing files this visitor uploaded + * for this conversation. Metadata is resolved server-side; unknown / foreign / expired + * ids are dropped. */ + private List attachmentIds; } /** Compact view of one of a visitor's conversation threads. */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java new file mode 100644 index 00000000..52e03d2a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java @@ -0,0 +1,219 @@ +package vip.mate.channel.webchat; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Storage + validation for files exchanged over the WebChat channel. + * + *

WebChat is reached by untrusted external visitors (API key + visitor + * token, no JWT), so uploads are hardened here: size cap, extension allow-list, + * filename sanitization, and a server-issued stored name. Every path is derived + * from the server-computed {@code conversationId} — never from a client-supplied + * path — and download resolution is traversal-guarded. + * + *

Uploads are staged in an in-memory registry keyed by an opaque file id. + * Only when the visitor references that id on the next {@code /stream} call does + * the file become a real conversation attachment (the bytes already live under + * the conversation's upload dir, so cleanup rides the existing + * {@code cleanAttachmentFiles} cascade). Unreferenced staged files are swept + * after {@link #STAGING_TTL_MS}. + */ +@Slf4j +@Service +public class WebChatFileService { + + /** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */ + private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads"); + + /** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */ + private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour + + private final boolean enabled; + private final long maxSizeBytes; + private final Set allowedExtensions; + + /** fileId (== storedName) -> staged metadata, pending a /stream reference. */ + private final ConcurrentHashMap staged = new ConcurrentHashMap<>(); + + public WebChatFileService( + @Value("${mateclaw.webchat.upload.enabled:true}") boolean enabled, + @Value("${mateclaw.webchat.upload.max-size-mb:20}") long maxSizeMb, + @Value("${mateclaw.webchat.upload.allowed-extensions:" + + "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log," + + "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv) { + this.enabled = enabled; + this.maxSizeBytes = maxSizeMb * 1024 * 1024; + this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(",")) + .map(s -> s.trim().toLowerCase(Locale.ROOT)) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + /** Metadata for a staged upload. */ + public record StagedFile(String conversationId, String storedName, String originalName, + String contentType, long size, long expireAt) { + boolean expired() { + return System.currentTimeMillis() > expireAt; + } + } + + /** Thrown on any validation failure; the controller maps it to a 4xx. */ + public static class UploadRejectedException extends RuntimeException { + public UploadRejectedException(String message) { + super(message); + } + } + + public boolean isEnabled() { + return enabled; + } + + /** + * Validate and store an uploaded file under the conversation's upload dir, + * returning a staged record whose {@code storedName} doubles as the opaque + * file id the visitor references on the next /stream call. + * + * @param conversationId server-derived conversation id (never client-supplied) + */ + public StagedFile store(String conversationId, MultipartFile file) throws IOException { + if (!enabled) { + throw new UploadRejectedException("WebChat file upload is disabled"); + } + if (file == null || file.isEmpty()) { + throw new UploadRejectedException("Empty file"); + } + if (file.getSize() > maxSizeBytes) { + throw new UploadRejectedException("File too large (max " + (maxSizeBytes / 1024 / 1024) + " MB)"); + } + + String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; + // Strip any directory components, then collapse to a safe charset. + String baseName = Paths.get(originalName).getFileName().toString(); + String ext = extensionOf(baseName); + if (ext.isEmpty() || !allowedExtensions.contains(ext)) { + throw new UploadRejectedException("File type not allowed: ." + ext); + } + String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_"); + + String storedName = UUID.randomUUID() + "_" + safeName; + Path dir = UPLOAD_ROOT.resolve(conversationId).normalize(); + if (!dir.startsWith(UPLOAD_ROOT.normalize())) { + // conversationId is server-derived, so this should never happen; fail closed if it does. + throw new UploadRejectedException("Invalid conversation"); + } + Files.createDirectories(dir); + Path target = dir.resolve(storedName); + file.transferTo(target.toAbsolutePath()); + + String contentType = Optional.ofNullable(file.getContentType()) + .filter(ct -> !ct.isBlank()) + .orElseGet(() -> probe(target)); + + StagedFile entry = new StagedFile(conversationId, storedName, baseName, contentType, + file.getSize(), System.currentTimeMillis() + STAGING_TTL_MS); + staged.put(storedName, entry); + log.info("[webchat-file] Stored upload conv={} stored={} type={} size={}", + conversationId, storedName, contentType, file.getSize()); + return entry; + } + + /** + * Resolve a staged file id into its metadata, asserting it belongs to this + * conversation and has not expired. Consuming it removes the staging entry + * (the bytes remain as a committed conversation attachment). Returns empty + * if the id is unknown, expired, or belongs to another conversation. + */ + public Optional consume(String conversationId, String fileId) { + if (fileId == null) { + return Optional.empty(); + } + StagedFile entry = staged.get(fileId); + if (entry == null || entry.expired() || !entry.conversationId().equals(conversationId)) { + return Optional.empty(); + } + staged.remove(fileId); + return Optional.of(entry); + } + + /** + * Traversal-safe resolution of a stored file under the conversation's dir. + * Both the dir and the final path are derived from the server-computed + * conversationId; the client-supplied {@code storedName} is confined by the + * {@code startsWith} guard. Returns empty if missing or escaping the dir. + */ + public Optional resolve(String conversationId, String storedName) { + if (storedName == null || storedName.isBlank()) { + return Optional.empty(); + } + Path base = UPLOAD_ROOT.resolve(conversationId).normalize(); + Path file = base.resolve(storedName).normalize(); + if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) { + return Optional.empty(); + } + return Optional.of(file); + } + + /** Map a content type to the MessageContentPart type the agent/UI understands. */ + public static String partTypeFor(String contentType) { + if (contentType == null) { + return "file"; + } + String ct = contentType.toLowerCase(Locale.ROOT); + if (ct.startsWith("image/")) return "image"; + if (ct.startsWith("video/")) return "video"; + if (ct.startsWith("audio/")) return "audio"; + return "file"; + } + + /** Periodically drop staged files the visitor never referenced. */ + @Scheduled(fixedDelay = 15 * 60 * 1000L) + public void sweepExpired() { + staged.values().removeIf(entry -> { + if (!entry.expired()) { + return false; + } + resolve(entry.conversationId(), entry.storedName()).ifPresent(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + log.warn("[webchat-file] Failed to delete expired staged file {}: {}", + p, e.getMessage()); + } + }); + return true; + }); + } + + private static String extensionOf(String name) { + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return ""; + } + return name.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private static String probe(Path path) { + try { + String ct = Files.probeContentType(path); + return ct != null ? ct : "application/octet-stream"; + } catch (IOException e) { + return "application/octet-stream"; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 0b5f08b3..dcf761f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -854,6 +854,27 @@ public class ConversationService { .toList(); } + /** + * External-facing message views for untrusted callers (webchat visitors). + * Strips the server-side absolute file path from both the structured parts + * ({@code path} nulled) and the rendered text, so the server filesystem + * layout is never disclosed. Visitors still get {@code fileUrl} / {@code + * fileName} / {@code contentType} to render and download attachments. + */ + public List listMessageViewsExternal(String conversationId) { + return listMessages(conversationId).stream() + .map(message -> { + List parts = parseMessageParts(message); + parts.forEach(p -> { + if (p != null) { + p.setPath(null); + } + }); + return MessageVO.from(message, parts, renderMessageContent(message, false)); + }) + .toList(); + } + /** * Delete a conversation and cascade-clean every row that referenced it. *

@@ -983,6 +1004,16 @@ public class ConversationService { } public String renderMessageContent(MessageEntity message) { + return renderMessageContent(message, true); + } + + /** + * Render variant whose {@code includePath} controls whether the server-side + * file path is embedded in the text. Internal/LLM rendering keeps it (tools + * resolve files by path); external rendering (webchat visitors) drops it so + * the server filesystem layout is not disclosed to untrusted callers. + */ + public String renderMessageContent(MessageEntity message, boolean includePath) { List parts = parseMessageParts(message); if (parts.isEmpty()) { return message.getContent() != null ? message.getContent() : ""; @@ -996,8 +1027,8 @@ public class ConversationService { switch (part.getType()) { case "text" -> appendSegment(text, part.getText()); case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ } - case "file" -> appendSegment(text, renderFilePart(part)); - case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part)); + case "file" -> appendSegment(text, renderFilePart(part, includePath)); + case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part, includePath)); default -> appendSegment(text, part.getText()); } } @@ -1047,10 +1078,10 @@ public class ConversationService { * picks (read_file / extract_document_text / detect_file_type / …) can be called * with a path that resolves directly, instead of relying on per-tool fallbacks. */ - private String renderFilePart(MessageContentPart part) { + private String renderFilePart(MessageContentPart part, boolean includePath) { String name = safe(part.getFileName()); String path = safe(part.getPath()); - if (path.isBlank()) { + if (!includePath || path.isBlank()) { return "[附件] " + name; } return "[附件] " + name + "(路径: " + path + ")"; @@ -1067,7 +1098,7 @@ public class ConversationService { * already uploaded. The path lets file-reading tools ({@code read_file}, * {@code extract_document_text}, {@code detect_file_type}) work as a fallback. */ - private String renderMediaPart(MessageContentPart part) { + private String renderMediaPart(MessageContentPart part, boolean includePath) { String label = switch (part.getType()) { case "image" -> "[图片]"; case "video" -> "[视频]"; @@ -1081,7 +1112,7 @@ public class ConversationService { } String path = safe(part.getPath()); StringBuilder rendered = new StringBuilder(label).append(' ').append(name); - if (!path.isBlank()) { + if (includePath && !path.isBlank()) { rendered.append("(路径: ").append(path).append(")"); } // A persisted caption (vision sidecar output) carries the image content diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java new file mode 100644 index 00000000..573467e4 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java @@ -0,0 +1,102 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Validation + isolation contract for {@link WebChatFileService}. WebChat + * uploads come from untrusted external visitors, so these pin: extension + * whitelist, size cap, disabled-switch, per-conversation ownership of staged + * ids, and traversal-safe resolution. + */ +class WebChatFileServiceTest { + + private static final String CONV = "webchat:abcd1234:visitor-1"; + + private WebChatFileService service(boolean enabled, long maxMb, String exts) { + return new WebChatFileService(enabled, maxMb, exts); + } + + @AfterEach + void cleanup() throws IOException { + Path dir = Paths.get("data", "chat-uploads", CONV); + if (Files.exists(dir)) { + try (Stream walk = Files.walk(dir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) { } + }); + } + } + } + + @Test + @DisplayName("rejects a disallowed extension") + void rejectsDisallowedExtension() { + WebChatFileService svc = service(true, 20, "png,txt"); + MockMultipartFile evil = new MockMultipartFile("file", "evil.exe", + "application/octet-stream", new byte[]{1, 2, 3}); + assertThatThrownBy(() -> svc.store(CONV, evil)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("rejects an oversized file") + void rejectsOversized() { + WebChatFileService svc = service(true, 1, "png"); + byte[] big = new byte[2 * 1024 * 1024]; // 2MB > 1MB cap + MockMultipartFile file = new MockMultipartFile("file", "big.png", "image/png", big); + assertThatThrownBy(() -> svc.store(CONV, file)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("rejects when disabled") + void rejectsWhenDisabled() { + WebChatFileService svc = service(false, 20, "png"); + MockMultipartFile file = new MockMultipartFile("file", "ok.png", "image/png", new byte[]{1}); + assertThatThrownBy(() -> svc.store(CONV, file)) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + + @Test + @DisplayName("accepts allowed file; consume is one-shot and conversation-scoped") + void acceptsAndConsumeIsScoped() throws IOException { + WebChatFileService svc = service(true, 20, "png,txt"); + MockMultipartFile file = new MockMultipartFile("file", "hello.txt", "text/plain", + "hi".getBytes()); + + WebChatFileService.StagedFile stored = svc.store(CONV, file); + assertThat(stored.originalName()).isEqualTo("hello.txt"); + assertThat(stored.conversationId()).isEqualTo(CONV); + + // Foreign conversation can't consume it. + assertThat(svc.consume("webchat:abcd1234:other", stored.storedName())).isEmpty(); + // Owning conversation can — exactly once. + assertThat(svc.consume(CONV, stored.storedName())).isPresent(); + assertThat(svc.consume(CONV, stored.storedName())).isEmpty(); + + // Bytes survive on disk for download after consume. + assertThat(svc.resolve(CONV, stored.storedName())).isPresent(); + } + + @Test + @DisplayName("resolve is traversal-safe") + void resolveRejectsTraversal() { + WebChatFileService svc = service(true, 20, "png"); + Optional escaped = svc.resolve(CONV, "../../../../etc/passwd"); + assertThat(escaped).isEmpty(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java new file mode 100644 index 00000000..d7b6e5ed --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceExternalViewTest.java @@ -0,0 +1,84 @@ +package vip.mate.workspace.conversation; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.conversation.vo.MessageVO; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Pin that the external (webchat-facing) message view never leaks the + * server-side absolute file path — neither in the structured {@code path} field + * nor in the rendered text — while the internal view still carries it for the + * agent's file tools. + */ +@ExtendWith(MockitoExtension.class) +class ConversationServiceExternalViewTest { + + @Mock private ConversationMapper conversationMapper; + @Mock private MessageMapper messageMapper; + @Mock private AgentMapper agentMapper; + @Spy private ObjectMapper objectMapper = new ObjectMapper(); + + @InjectMocks private ConversationService service; + + private static final String SECRET_PATH = "/srv/mateclaw/data/chat-uploads/secret/doc.pdf"; + + private MessageEntity fileMessage() { + MessageEntity m = new MessageEntity(); + m.setId(1L); + m.setConversationId("c1"); + m.setRole("assistant"); + m.setContent("here is your file"); + m.setContentParts("[{\"type\":\"file\",\"fileName\":\"doc.pdf\",\"path\":\"" + + SECRET_PATH + "\"}]"); + m.setStatus("completed"); + m.setCreateTime(LocalDateTime.now()); + return m; + } + + @Test + @DisplayName("external view nulls part.path and omits path from rendered text") + void externalViewStripsPath() { + when(messageMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(fileMessage())); + + List views = service.listMessageViewsExternal("c1"); + + assertThat(views).hasSize(1); + MessageVO vo = views.get(0); + assertThat(vo.getContentParts().get(0).getPath()).isNull(); + assertThat(vo.getContent()).doesNotContain(SECRET_PATH); + assertThat(vo.getContent()).doesNotContain("路径"); + // filename still surfaced so the visitor can recognize the attachment + assertThat(vo.getContent()).contains("doc.pdf"); + } + + @Test + @DisplayName("internal view keeps the path for the agent's file tools") + void internalViewKeepsPath() { + when(messageMapper.selectList(any(LambdaQueryWrapper.class))) + .thenReturn(List.of(fileMessage())); + + List views = service.listMessageViews("c1"); + + assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH); + assertThat(views.get(0).getContent()).contains(SECRET_PATH); + } +}