mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import vip.mate.channel.web.Utf8SseEmitter;
|
import vip.mate.channel.web.Utf8SseEmitter;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
@ -63,6 +72,7 @@ public class WebChatController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ConversationCompletionPublisher completionPublisher;
|
private final ConversationCompletionPublisher completionPublisher;
|
||||||
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
|
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
|
* 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;
|
Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L;
|
||||||
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId);
|
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId);
|
||||||
|
|
||||||
// 保存用户消息
|
// 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查,
|
||||||
conversationService.saveMessage(conversationId, "user", message, List.of());
|
// 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。
|
||||||
|
List<MessageContentPart> userParts = buildUserParts(conversationId, message, request.getAttachmentIds());
|
||||||
|
conversationService.saveMessage(conversationId, "user", message, userParts);
|
||||||
|
|
||||||
// 初始化 SSE 流跟踪
|
// 初始化 SSE 流跟踪
|
||||||
streamTracker.register(conversationId);
|
streamTracker.register(conversationId);
|
||||||
@ -337,7 +349,8 @@ public class WebChatController {
|
|||||||
if (!ownsConversation(conversationId, visitorId)) {
|
if (!ownsConversation(conversationId, visitorId)) {
|
||||||
return R.fail(404, "Session not found");
|
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();
|
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}");
|
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.
|
/** Optional: open a distinct conversation thread for the same visitor.
|
||||||
* Composed into the server-derived conversationId; never used as a raw conversationId. */
|
* Composed into the server-derived conversationId; never used as a raw conversationId. */
|
||||||
private String sessionId;
|
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. */
|
/** 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();
|
.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.
|
* Delete a conversation and cascade-clean every row that referenced it.
|
||||||
* <p>
|
* <p>
|
||||||
@ -983,6 +1004,16 @@ public class ConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String renderMessageContent(MessageEntity message) {
|
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);
|
List<MessageContentPart> parts = parseMessageParts(message);
|
||||||
if (parts.isEmpty()) {
|
if (parts.isEmpty()) {
|
||||||
return message.getContent() != null ? message.getContent() : "";
|
return message.getContent() != null ? message.getContent() : "";
|
||||||
@ -996,8 +1027,8 @@ public class ConversationService {
|
|||||||
switch (part.getType()) {
|
switch (part.getType()) {
|
||||||
case "text" -> appendSegment(text, part.getText());
|
case "text" -> appendSegment(text, part.getText());
|
||||||
case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ }
|
case "thinking", "tool_call", "parse_error" -> { /* skip — frontend reads these from contentParts directly */ }
|
||||||
case "file" -> appendSegment(text, renderFilePart(part));
|
case "file" -> appendSegment(text, renderFilePart(part, includePath));
|
||||||
case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part));
|
case "image", "video", "audio", "model3d" -> appendSegment(text, renderMediaPart(part, includePath));
|
||||||
default -> appendSegment(text, part.getText());
|
default -> appendSegment(text, part.getText());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1047,10 +1078,10 @@ public class ConversationService {
|
|||||||
* picks (read_file / extract_document_text / detect_file_type / …) can be called
|
* 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.
|
* 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 name = safe(part.getFileName());
|
||||||
String path = safe(part.getPath());
|
String path = safe(part.getPath());
|
||||||
if (path.isBlank()) {
|
if (!includePath || path.isBlank()) {
|
||||||
return "[附件] " + name;
|
return "[附件] " + name;
|
||||||
}
|
}
|
||||||
return "[附件] " + name + "(路径: " + path + ")";
|
return "[附件] " + name + "(路径: " + path + ")";
|
||||||
@ -1067,7 +1098,7 @@ public class ConversationService {
|
|||||||
* already uploaded. The path lets file-reading tools ({@code read_file},
|
* already uploaded. The path lets file-reading tools ({@code read_file},
|
||||||
* {@code extract_document_text}, {@code detect_file_type}) work as a fallback.
|
* {@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()) {
|
String label = switch (part.getType()) {
|
||||||
case "image" -> "[图片]";
|
case "image" -> "[图片]";
|
||||||
case "video" -> "[视频]";
|
case "video" -> "[视频]";
|
||||||
@ -1081,7 +1112,7 @@ public class ConversationService {
|
|||||||
}
|
}
|
||||||
String path = safe(part.getPath());
|
String path = safe(part.getPath());
|
||||||
StringBuilder rendered = new StringBuilder(label).append(' ').append(name);
|
StringBuilder rendered = new StringBuilder(label).append(' ').append(name);
|
||||||
if (!path.isBlank()) {
|
if (includePath && !path.isBlank()) {
|
||||||
rendered.append("(路径: ").append(path).append(")");
|
rendered.append("(路径: ").append(path).append(")");
|
||||||
}
|
}
|
||||||
// A persisted caption (vision sidecar output) carries the image content
|
// 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