feat(webchat): support inbound file upload and outbound download

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
This commit is contained in:
倪程伟 2026-06-17 21:30:13 +08:00 committed by matevip
parent 8e339f083a
commit 594880bd64
5 changed files with 611 additions and 9 deletions

View File

@ -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<MessageContentPart> 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 中引用
* <p>鉴权同会话接口API Key + visitor tokenconversationId 服务端派生
*/
@Operation(summary = "WebChat 上传文件")
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<Map<String, Object>> 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<Resource> 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<MessageContentPart> buildUserParts(String conversationId, String message,
List<String> attachmentIds) {
List<MessageContentPart> 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<String> attachmentIds;
}
/** Compact view of one of a visitor's conversation threads. */

View File

@ -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.
*
* <p>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.
*
* <p>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<String> allowedExtensions;
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
private final ConcurrentHashMap<String, StagedFile> 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<StagedFile> 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<Path> 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";
}
}
}

View File

@ -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<MessageVO> listMessageViewsExternal(String conversationId) {
return listMessages(conversationId).stream()
.map(message -> {
List<MessageContentPart> 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.
* <p>
@ -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<MessageContentPart> 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

View File

@ -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<Path> 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<Path> escaped = svc.resolve(CONV, "../../../../etc/passwd");
assertThat(escaped).isEmpty();
}
}

View File

@ -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<MessageVO> 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<MessageVO> views = service.listMessageViews("c1");
assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH);
assertThat(views.get(0).getContent()).contains(SECRET_PATH);
}
}