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.
This commit is contained in:
倪程伟 2026-06-17 22:19:44 +08:00 committed by matevip
parent ee3e391977
commit bdda9c7357
4 changed files with 78 additions and 5 deletions

View File

@ -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();
}

View File

@ -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<String> allowedExtensions;
private final int maxFilesPerConversation;
private final long maxTotalBytesPerConversation;
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
private final ConcurrentHashMap<String, StagedFile> 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<Path> files = Files.list(dir)) {
for (Path p : (Iterable<Path>) 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) {

View File

@ -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.
* <p>
* Scoped to {@code username = owner} only unlike {@link #listConversations}
* it does <b>not</b> 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<ConversationEntity> listWebchatConversations(String username) {
return conversationMapper.selectList(new LambdaQueryWrapper<ConversationEntity>()
.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}.

View File

@ -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() {