mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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 base = deriveConversationId(apiKey, visitorId, null);
|
||||||
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
|
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
|
||||||
String owner = webchatUsername(visitorId);
|
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
|
.filter(c -> c.getConversationId() != null
|
||||||
&& owner.equals(c.getUsername())
|
|
||||||
&& c.getConversationId().startsWith(channelPrefix))
|
&& c.getConversationId().startsWith(channelPrefix))
|
||||||
.map(c -> {
|
.map(c -> {
|
||||||
String sid = recoverSessionId(c, base);
|
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
|
* Returns null for the default (no-session) thread and for legacy hashed rows
|
||||||
* whose sessionId can no longer be reconstructed.
|
* 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) {
|
if (c.getWebchatSessionId() != null) {
|
||||||
return c.getWebchatSessionId();
|
return c.getWebchatSessionId();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import java.util.Set;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage + validation for files exchanged over the WebChat channel.
|
* Storage + validation for files exchanged over the WebChat channel.
|
||||||
@ -47,6 +48,8 @@ public class WebChatFileService {
|
|||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
private final long maxSizeBytes;
|
private final long maxSizeBytes;
|
||||||
private final Set<String> allowedExtensions;
|
private final Set<String> allowedExtensions;
|
||||||
|
private final int maxFilesPerConversation;
|
||||||
|
private final long maxTotalBytesPerConversation;
|
||||||
|
|
||||||
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
|
||||||
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>();
|
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.max-size-mb:20}") long maxSizeMb,
|
||||||
@Value("${mateclaw.webchat.upload.allowed-extensions:"
|
@Value("${mateclaw.webchat.upload.allowed-extensions:"
|
||||||
+ "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log,"
|
+ "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.enabled = enabled;
|
||||||
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
|
||||||
.map(s -> s.trim().toLowerCase(Locale.ROOT))
|
.map(s -> s.trim().toLowerCase(Locale.ROOT))
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
this.maxFilesPerConversation = maxFilesPerConversation;
|
||||||
|
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Metadata for a staged upload. */
|
/** Metadata for a staged upload. */
|
||||||
@ -118,6 +125,7 @@ public class WebChatFileService {
|
|||||||
throw new UploadRejectedException("Invalid conversation");
|
throw new UploadRejectedException("Invalid conversation");
|
||||||
}
|
}
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
|
enforceConversationQuota(dir, file.getSize());
|
||||||
Path target = dir.resolve(storedName);
|
Path target = dir.resolve(storedName);
|
||||||
file.transferTo(target.toAbsolutePath());
|
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) {
|
private static String extensionOf(String name) {
|
||||||
int dot = name.lastIndexOf('.');
|
int dot = name.lastIndexOf('.');
|
||||||
if (dot < 0 || dot == name.length() - 1) {
|
if (dot < 0 || dot == name.length() - 1) {
|
||||||
|
|||||||
@ -320,6 +320,24 @@ public class ConversationService {
|
|||||||
return conv;
|
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
|
* Create a child conversation (delegation scenario), linking it back to
|
||||||
* its parent via {@code parentConversationId}.
|
* its parent via {@code parentConversationId}.
|
||||||
|
|||||||
@ -27,7 +27,12 @@ class WebChatFileServiceTest {
|
|||||||
private static final String CONV = "webchat:abcd1234:visitor-1";
|
private static final String CONV = "webchat:abcd1234:visitor-1";
|
||||||
|
|
||||||
private WebChatFileService service(boolean enabled, long maxMb, String exts) {
|
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
|
@AfterEach
|
||||||
@ -92,6 +97,17 @@ class WebChatFileServiceTest {
|
|||||||
assertThat(svc.resolve(CONV, stored.storedName())).isPresent();
|
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
|
@Test
|
||||||
@DisplayName("resolve is traversal-safe")
|
@DisplayName("resolve is traversal-safe")
|
||||||
void resolveRejectsTraversal() {
|
void resolveRejectsTraversal() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user