mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
8e339f083a
commit
594880bd64
@ -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 token;conversationId 服务端派生。
|
||||
*/
|
||||
@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. */
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user