From bdda9c7357b4a6a9861c0f21b2f895a6a7d73019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Wed, 17 Jun 2026 22:19:44 +0800 Subject: [PATCH] perf(webchat): scope session listing to the visitor; cap upload disk use Two webchat hardening fixes: - Session listing no longer pulls every system-owned conversation into memory. listSessions/pageSessions went through listConversations(owner) whose `username IN (owner, system)` loaded all IM/cron rows just to show one visitor's handful of threads. New listWebchatConversations(username) queries only the visitor's own rows; the channel prefix is matched in-memory with a literal startsWith (so a '_'/'%' in the api key's first 8 chars can't act as a LIKE wildcard). - Upload now enforces a per-conversation quota (file count + total bytes, both configurable) so a visitor can't fill the disk with many individually-under-cap files. Pairs with the existing staging TTL sweep. --- .../channel/webchat/WebChatController.java | 9 +++-- .../channel/webchat/WebChatFileService.java | 38 ++++++++++++++++++- .../conversation/ConversationService.java | 18 +++++++++ .../webchat/WebChatFileServiceTest.java | 18 ++++++++- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index f1a22f15..215042ee 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -405,9 +405,12 @@ public class WebChatController { String base = deriveConversationId(apiKey, visitorId, null); String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; String owner = webchatUsername(visitorId); - return conversationService.listConversations(owner).stream() + // Query is scoped to this visitor's own rows only (no system rows), so + // listing a visitor's threads doesn't load every IM/cron conversation. + // The channel prefix is matched in-memory with a literal startsWith so a + // '_' / '%' in the api key's first 8 chars can't act as a LIKE wildcard. + return conversationService.listWebchatConversations(owner).stream() .filter(c -> c.getConversationId() != null - && owner.equals(c.getUsername()) && c.getConversationId().startsWith(channelPrefix)) .map(c -> { String sid = recoverSessionId(c, base); @@ -422,7 +425,7 @@ public class WebChatController { * Returns null for the default (no-session) thread and for legacy hashed rows * whose sessionId can no longer be reconstructed. */ - private String recoverSessionId(vip.mate.workspace.conversation.vo.ConversationVO c, String base) { + private String recoverSessionId(vip.mate.workspace.conversation.model.ConversationEntity c, String base) { if (c.getWebchatSessionId() != null) { return c.getWebchatSessionId(); } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java index 52e03d2a..defcc725 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatFileService.java @@ -17,6 +17,7 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Storage + validation for files exchanged over the WebChat channel. @@ -47,6 +48,8 @@ public class WebChatFileService { private final boolean enabled; private final long maxSizeBytes; private final Set allowedExtensions; + private final int maxFilesPerConversation; + private final long maxTotalBytesPerConversation; /** fileId (== storedName) -> staged metadata, pending a /stream reference. */ private final ConcurrentHashMap staged = new ConcurrentHashMap<>(); @@ -56,13 +59,17 @@ public class WebChatFileService { @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) { + + "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv, + @Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation, + @Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) { 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()); + this.maxFilesPerConversation = maxFilesPerConversation; + this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024; } /** Metadata for a staged upload. */ @@ -118,6 +125,7 @@ public class WebChatFileService { throw new UploadRejectedException("Invalid conversation"); } Files.createDirectories(dir); + enforceConversationQuota(dir, file.getSize()); Path target = dir.resolve(storedName); file.transferTo(target.toAbsolutePath()); @@ -200,6 +208,34 @@ public class WebChatFileService { }); } + /** + * Bound a conversation's disk footprint: reject when the dir already holds + * the max file count, or when adding {@code incomingSize} would push the + * total over the cap. Cheap dir scan (these dirs hold at most a few dozen + * files); pairs with the staging TTL sweep that reclaims unreferenced files. + */ + private void enforceConversationQuota(Path dir, long incomingSize) throws IOException { + int count = 0; + long total = 0; + try (Stream files = Files.list(dir)) { + for (Path p : (Iterable) files::iterator) { + if (Files.isRegularFile(p)) { + count++; + total += Files.size(p); + } + } + } + if (count >= maxFilesPerConversation) { + throw new UploadRejectedException( + "Too many files in this conversation (max " + maxFilesPerConversation + ")"); + } + if (total + incomingSize > maxTotalBytesPerConversation) { + throw new UploadRejectedException( + "Conversation upload quota exceeded (max " + + (maxTotalBytesPerConversation / 1024 / 1024) + " MB)"); + } + } + private static String extensionOf(String name) { int dot = name.lastIndexOf('.'); if (dot < 0 || dot == name.length() - 1) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 8853c76a..ad1045bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -320,6 +320,24 @@ public class ConversationService { return conv; } + /** + * List a webchat visitor's own conversations (top-level threads), ordered + * pinned-desc then last-active-desc. + *

+ * Scoped to {@code username = owner} only — unlike {@link #listConversations} + * it does not pull in {@code system} rows, so a visitor's /sessions + * call doesn't load every IM/cron conversation in the database just to list + * its own handful of threads. The caller still applies the channel-prefix + * filter (literal {@code startsWith}, wildcard-safe) to isolate the channel. + */ + public List listWebchatConversations(String username) { + return conversationMapper.selectList(new LambdaQueryWrapper() + .eq(ConversationEntity::getUsername, username) + .isNull(ConversationEntity::getParentConversationId) + .orderByDesc(ConversationEntity::getPinned) + .orderByDesc(ConversationEntity::getLastActiveTime)); + } + /** * Create a child conversation (delegation scenario), linking it back to * its parent via {@code parentConversationId}. diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java index 573467e4..6a9fe1b5 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatFileServiceTest.java @@ -27,7 +27,12 @@ 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); + return new WebChatFileService(enabled, maxMb, exts, 50, 200); + } + + private WebChatFileService service(boolean enabled, long maxMb, String exts, + int maxFiles, long maxTotalMb) { + return new WebChatFileService(enabled, maxMb, exts, maxFiles, maxTotalMb); } @AfterEach @@ -92,6 +97,17 @@ class WebChatFileServiceTest { assertThat(svc.resolve(CONV, stored.storedName())).isPresent(); } + @Test + @DisplayName("rejects once the per-conversation file-count quota is hit") + void rejectsOverFileCountQuota() throws IOException { + WebChatFileService svc = service(true, 20, "txt", 2, 200); // max 2 files + svc.store(CONV, new MockMultipartFile("file", "a.txt", "text/plain", "a".getBytes())); + svc.store(CONV, new MockMultipartFile("file", "b.txt", "text/plain", "b".getBytes())); + assertThatThrownBy(() -> svc.store(CONV, + new MockMultipartFile("file", "c.txt", "text/plain", "c".getBytes()))) + .isInstanceOf(WebChatFileService.UploadRejectedException.class); + } + @Test @DisplayName("resolve is traversal-safe") void resolveRejectsTraversal() {