mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
ee3e391977
commit
bdda9c7357
@ -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();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}.
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user