diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 23db1dd8..d5eb3a7c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -1913,11 +1913,11 @@ public class ChannelMessageRouter { */ private Path resolveVoiceReplyAudio(String conversationId, String fileName) { if (chatUploadLocationResolver != null) { - for (Path dir : chatUploadLocationResolver.resolveCandidateConversationDirs(conversationId)) { - Path candidate = dir.resolve(fileName); - if (Files.exists(candidate)) { - return candidate; - } + // Probes every candidate root and both layouts (flat + date + // sub-directories), so TTS files written under a per-day dir resolve. + Path found = chatUploadLocationResolver.resolveExistingFile(conversationId, fileName); + if (found != null) { + return found; } } // Fallback to the legacy default dir when the resolver is absent diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 5134bfb0..f82f9c34 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -240,19 +240,6 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre */ vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver; - /** - * Resolve the upload root for a conversation, preferring the wired resolver - * (workspace/agent-aware) and falling back to the legacy field. Read paths - * should use {@link #candidateChatUploadRoots(String)} to probe both the - * workspace-scoped root and the legacy root. - */ - private java.nio.file.Path chatUploadRootFor(String conversationId) { - if (chatUploadLocationResolver != null) { - return chatUploadLocationResolver.resolveUploadRoot(conversationId); - } - return chatUploadsRoot; - } - /** * Ordered candidate upload roots for a conversation: workspace-scoped first * (when the resolver is wired), then the legacy field. Used by read/scan @@ -1791,11 +1778,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre : maybeDownloadResource(messageId, fileKey, type, fileName); if (dl == null) return null; - // Save under the workspace/agent-aware upload root ({convId}/ subdir). - // Sanitize the id for the path segment — IM ids like "feishu:xxx" + // Save under the workspace/agent-aware upload root ({convId}/ subdir, + // plus the per-day sub-directory when date folders are enabled). + // The id is sanitized for the path segment — IM ids like "feishu:xxx" // carry a ':' that is illegal in a Windows filename. - Path uploadDir = chatUploadRootFor(conversationId) - .resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)); + Path uploadDir = (chatUploadLocationResolver != null) + ? chatUploadLocationResolver.resolveWriteDir(conversationId) + : chatUploadsRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)); Files.createDirectories(uploadDir); String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) ? dl.fileName() : fileKey; @@ -1889,7 +1878,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; List merged = new java.util.ArrayList<>(); for (Path dir : candidateChatUploadDirs(conversationId)) { - merged.addAll(loadRecentFilesFromDisk(dir, cutoff)); + // Scan the flat conversation dir plus each yyyy-MM-dd sub-directory + // so staged copies written under either layout are recovered. + for (Path scanDir : ChatUploadLocationResolver.dateScanDirs(dir)) { + merged.addAll(loadRecentFilesFromDisk(scanDir, cutoff)); + } } return merged; } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index a8334358..3ee7d871 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1144,12 +1144,12 @@ public class ChatController { String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_"); String storedName = System.currentTimeMillis() + "_" + safeFilename; Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId); - // Sanitize the id before using it as a path segment — IM-channel ids like - // "wecom:XXXX" carry a ':' that is illegal in a Windows filename and would - // throw InvalidPathException here. Reads use the same sanitization. - Path conversationDir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)); - Files.createDirectories(conversationDir); - Path target = conversationDir.resolve(storedName); + // resolveWriteDir sanitizes the id (IM-channel ids like "wecom:XXXX" + // carry a ':' illegal on Windows) and appends the per-day sub-directory + // when date folders are enabled. Reads probe both layouts. + Path writeDir = uploadLocationResolver.resolveWriteDir(conversationId); + Files.createDirectories(writeDir); + Path target = writeDir.resolve(storedName); file.transferTo(target); log.info("Chat attachment uploaded: conversationId={}, user={}, file={}", conversationId, username, target); @@ -1160,7 +1160,7 @@ public class ChatController { response.setStoredName(storedName); response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName); // 用 root 相对路径,避免暴露服务端绝对路径(uploadRoot 现在恒为绝对路径)。 - response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName)); + response.setPath(toRelativeUploadPath(uploadRoot, target)); response.setSize(file.getSize()); response.setContentType(file.getContentType()); return R.ok(response); @@ -1248,18 +1248,12 @@ public class ChatController { /** * Resolve an uploaded attachment to its on-disk path, probing every * candidate conversation dir (workspace-scoped + legacy default, sanitized + - * raw id) with a per-candidate path-traversal guard. Returns {@code null} - * when no candidate holds the file. + * raw id) and both layouts (flat + date sub-directories) with a + * path-traversal guard. Returns {@code null} when no candidate holds the + * file. */ private Path resolveUploadedFile(String conversationId, String storedName) { - for (Path conversationDir : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) { - Path normDir = conversationDir.normalize(); - Path candidate = normDir.resolve(storedName).normalize(); - if (Files.exists(candidate) && candidate.startsWith(normDir)) { - return candidate; - } - } - return null; + return uploadLocationResolver.resolveExistingFile(conversationId, storedName); } /** @@ -1662,8 +1656,7 @@ public class ChatController { * upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and * separators are normalized to {@code /} so the value is stable across OSes. */ - static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) { - Path target = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).resolve(storedName); + static String toRelativeUploadPath(Path uploadRoot, Path target) { Path base = uploadRoot.getParent(); Path relative = base != null ? base.relativize(target) : target; return relative.toString().replace('\\', '/'); 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 edbb0b64..28afbaf8 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 @@ -120,15 +120,19 @@ public class WebChatFileService { String storedName = UUID.randomUUID() + "_" + safeName; Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize(); - // Sanitize the id for the path segment (IM ids like "wecom:XXXX" carry a - // ':' illegal on Windows); reads use the same sanitization. - Path dir = uploadRoot.resolve(ChatUploadLocationResolver.sanitizeSegment(conversationId)).normalize(); + // resolveWriteDir sanitizes the id for the path segment (IM ids like + // "wecom:XXXX" carry a ':' illegal on Windows) and appends the per-day + // sub-directory when date folders are enabled. + Path dir = uploadLocationResolver.resolveWriteDir(conversationId).normalize(); if (!dir.startsWith(uploadRoot)) { // conversationId is server-derived, so this should never happen; fail closed if it does. throw new UploadRejectedException("Invalid conversation"); } Files.createDirectories(dir); - enforceConversationQuota(dir, file.getSize()); + // Quota counts the whole conversation tree (flat files + date subdirs), + // not just today's write dir. + enforceConversationQuota( + uploadLocationResolver.resolveConversationDir(conversationId).normalize(), file.getSize()); Path target = dir.resolve(storedName); file.transferTo(target.toAbsolutePath()); @@ -169,19 +173,10 @@ public class WebChatFileService { * {@code startsWith} guard. Returns empty if missing or escaping the dir. */ public Optional resolve(String conversationId, String storedName) { - if (storedName == null || storedName.isBlank()) { - return Optional.empty(); - } - // Check every candidate root (workspace-scoped dir + legacy default dir) - // so files written before the workspace-aware relocation still resolve. - for (Path base : uploadLocationResolver.resolveCandidateConversationDirs(conversationId)) { - Path normBase = base.normalize(); - Path file = normBase.resolve(storedName).normalize(); - if (file.startsWith(normBase) && Files.exists(file) && Files.isRegularFile(file)) { - return Optional.of(file); - } - } - return Optional.empty(); + // resolveExistingFile checks every candidate root (workspace-scoped dir + // + legacy default dir) and both layouts (flat + date sub-directories), + // guarding each candidate against traversal. + return Optional.ofNullable(uploadLocationResolver.resolveExistingFile(conversationId, storedName)); } /** Map a content type to the MessageContentPart type the agent/UI understands. */ @@ -216,19 +211,23 @@ 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. + * Bound a conversation's disk footprint: reject when the conversation tree + * already holds the max file count, or when adding {@code incomingSize} + * would push the total over the cap. Walks the tree so files under date + * sub-directories are counted; cheap 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 { + private void enforceConversationQuota(Path conversationDir, 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 (Files.isDirectory(conversationDir)) { + try (Stream files = Files.walk(conversationDir)) { + for (Path p : (Iterable) files::iterator) { + if (Files.isRegularFile(p)) { + count++; + total += Files.size(p); + } } } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index da5db099..c4bc205b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -3403,7 +3403,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter implements Strea // dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here // inside the byte source so a fetch + decrypt is retried as one unit. Path uploadDir = (chatUploadLocationResolver != null) - ? chatUploadLocationResolver.resolveConversationDir(conversationId) + ? chatUploadLocationResolver.resolveWriteDir(conversationId) : Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId)); String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint; return InboundMediaDownloader.download( diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index e415d4d3..8236aef7 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -695,7 +695,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { } Path uploadDir = (chatUploadLocationResolver != null) - ? chatUploadLocationResolver.resolveConversationDir(conversationId) + ? chatUploadLocationResolver.resolveWriteDir(conversationId) : Path.of("data", "chat-uploads", ChatUploadLocationResolver.sanitizeSegment(conversationId)); return InboundMediaDownloader.download( () -> client.downloadMedia("", aesKey, encryptQueryParam), diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java index 1007cec0..21e3a4d1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ChatUploadResolver.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -128,35 +129,86 @@ public final class ChatUploadResolver { if (!Files.isDirectory(uploadDir)) { return null; } - String basename; - try { - Path requested = Paths.get(rawPath).getFileName(); - basename = requested != null ? requested.toString() : null; - } catch (Exception e) { - return null; - } + String basename = basenameOf(rawPath); if (basename == null || basename.isBlank()) { return null; } - Path direct = uploadDir.resolve(basename); - if (Files.isRegularFile(direct)) { - return direct; - } + // Attachments may live flat in the conversation dir (legacy layout / + // date-folders off) or under a yyyy-MM-dd sub-directory; scan both. + List scanDirs = ChatUploadLocationResolver.dateScanDirs(uploadDir); - // Stored as "{millis}_{safeFilename}" where safeFilename replaces non-ASCII - // characters with underscores; match by sanitized basename suffix. - String safeBasename = basename.replaceAll("[^a-zA-Z0-9._-]", "_"); - String suffix = "_" + safeBasename; - try (var stream = Files.list(uploadDir)) { - return stream - .filter(Files::isRegularFile) - .filter(p -> p.getFileName().toString().endsWith(suffix)) - .findFirst() - .orElse(null); - } catch (IOException e) { - log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", uploadDir, e.getMessage()); + // An exact stored-name match is unambiguous, so it wins wherever it sits. + for (Path scanDir : scanDirs) { + Path direct = scanDir.resolve(basename); + if (Files.isRegularFile(direct)) { + return direct; + } + } + return newestSuffixMatch(scanDirs, basename); + } + + /** + * Last segment of a model-supplied path. The separator is not the running + * OS's: a model asked about an attachment routinely answers with a + * hallucinated {@code /app/report.pdf} or {@code C:\Users\me\report.pdf} + * regardless of the server platform, and on Linux a backslash is a legal + * file-name character, so {@code Path.getFileName()} alone would hand back + * the whole Windows-style string. Split on both separators. + */ + private static String basenameOf(String rawPath) { + String candidate; + try { + Path requested = Paths.get(rawPath).getFileName(); + candidate = requested != null ? requested.toString() : null; + } catch (Exception e) { return null; } + if (candidate == null) { + return null; + } + int backslash = candidate.lastIndexOf('\\'); + return backslash >= 0 ? candidate.substring(backslash + 1) : candidate; + } + + /** + * Fallback for when the model passes the original filename instead of the + * stored name: attachments are stored as {@code {millis}_{safeFilename}} + * with non-ASCII characters replaced by underscores, so match by sanitized + * basename suffix. The most recently modified match across every scan dir + * wins — re-uploading the same filename must resolve to today's copy, not + * to a same-named one left in the flat dir or an earlier day's dir. + *

+ * Equal timestamps are broken by file name so the pick stays deterministic + * on filesystems with coarse modification-time resolution (HFS+ stores + * whole seconds, FAT two): stored names are {@code {millis}_{name}}, so the + * lexicographically greater name is the later write. + */ + private static Path newestSuffixMatch(List scanDirs, String basename) { + String suffix = "_" + basename.replaceAll("[^a-zA-Z0-9._-]", "_"); + Path newest = null; + FileTime newestTime = null; + for (Path scanDir : scanDirs) { + if (!Files.isDirectory(scanDir)) { + continue; + } + try (var stream = Files.list(scanDir)) { + for (Path p : (Iterable) stream.filter(Files::isRegularFile) + .filter(f -> f.getFileName().toString().endsWith(suffix))::iterator) { + FileTime modified = Files.getLastModifiedTime(p); + int cmp = (newestTime == null) ? 1 : modified.compareTo(newestTime); + if (cmp == 0) { + cmp = p.getFileName().toString().compareTo(newest.getFileName().toString()); + } + if (cmp > 0) { + newest = p; + newestTime = modified; + } + } + } catch (IOException e) { + log.warn("[ChatUploadResolver] Failed to scan chat-upload dir {}: {}", scanDir, e.getMessage()); + } + } + return newest; } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java index 45cf1558..e4b98307 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageFileDownloader.java @@ -40,7 +40,7 @@ public class ImageFileDownloader { if (imageUrl == null) { throw new IOException("imageUrl is null"); } - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); if (imageUrl.startsWith("data:")) { @@ -112,7 +112,7 @@ public class ImageFileDownloader { * 将 Base64 编码的图片保存到本地 */ public Path saveBase64(String base64Data, String conversationId, String taskId, int index) throws IOException { - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); String fileName = "image_" + taskId + "_" + index + ".png"; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java index 56c3a9b3..d476d745 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/model3d/Model3dFileDownloader.java @@ -24,7 +24,7 @@ public class Model3dFileDownloader { public Path download(String modelUrl, String conversationId, String taskId, String preferredExtension) throws IOException { - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); String ext = guessExtension(modelUrl, preferredExtension); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java index d228db79..0998a532 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/music/MusicGenerationService.java @@ -191,7 +191,7 @@ public class MusicGenerationService { private PersistedAudio persistAudio(String conversationId, String taskId, MusicGenerationResult result) throws IOException { - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); String fileName = "music_" + taskId + "." + result.getFormat(); Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java index 856c6921..e81db205 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/video/VideoFileDownloader.java @@ -31,7 +31,7 @@ public class VideoFileDownloader { * @return 本地文件路径 */ public Path download(String videoUrl, String conversationId, String taskId) throws IOException { - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); String extension = guessExtension(videoUrl); diff --git a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java index f25fc16d..75a1d23e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java +++ b/mateclaw-server/src/main/java/vip/mate/tts/TtsService.java @@ -204,7 +204,7 @@ public class TtsService { private Path saveAudioFile(String conversationId, String fileId, byte[] data, String format) throws IOException { - Path dir = uploadLocationResolver.resolveConversationDir(conversationId); + Path dir = uploadLocationResolver.resolveWriteDir(conversationId); Files.createDirectories(dir); String fileName = "tts_" + fileId + "." + format; Path filePath = dir.resolve(fileName); diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java index 2233ec4a..12b146ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/config/ChatUploadProperties.java @@ -29,7 +29,26 @@ public class ChatUploadProperties { * Root directory for chat attachments when neither the active agent nor its * workspace configures a base path. Defaults to {@code data/chat-uploads} * (relative to the Spring Boot working directory). Conversations are stored - * one level below: {@code {baseDir}/{conversationId}/{storedName}}. + * one level below: {@code {baseDir}/{conversationId}/} — flat, or with a + * date level when {@link #dateFolders} is enabled. */ private String baseDir = "data/chat-uploads"; + + /** + * When {@code true} (the default), new attachments and generated media are + * written under a per-day sub-directory: + * {@code {conversationDir}/yyyy-MM-dd/{storedName}}. Long-lived + * conversations (IM channels keep one conversation per chat indefinitely) + * otherwise accumulate thousands of files in a single flat directory. + *

+ * Serving URLs stay flat ({@code /api/v1/chat/files/{convId}/{storedName}}); + * every read path probes the flat directory first and then each date + * sub-directory, so files written under either layout remain resolvable and + * the flag can be toggled at any time without migration. + *

+ * The day comes from the server's local date, so a container running in UTC + * groups files by UTC days. Reads never depend on it — they scan every date + * directory — so a timezone change only affects where the next write lands. + */ + private boolean dateFolders = true; } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java index f6f3c9e9..4a9a0754 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/ChatUploadLocationResolver.java @@ -17,14 +17,19 @@ import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.regex.Pattern; /** * Resolves the on-disk root directory where a conversation's chat attachments @@ -50,6 +55,13 @@ import java.util.Set; * resolvable and cleanable. Writes always target a single root returned by * {@link #resolveUploadRoot(String)}. * + *

Date folders

+ * When {@link ChatUploadProperties#isDateFolders()} is on (default), writes go + * through {@link #resolveWriteDir(String)} which appends a {@code yyyy-MM-dd} + * level under the conversation dir. Serving URLs stay flat; read paths probe + * both layouts via {@link #findInConversationDir(Path, String)} / + * {@link #dateScanDirs(Path)}. + * *

The {@code conversationId → ConversationEntity} lookup is cached for 5 * minutes (the mapping is immutable once a conversation exists), matching the * TTL of the existing {@code WorkspaceLookupCache} on the tool-call hot path. @@ -63,6 +75,14 @@ public class ChatUploadLocationResolver { /** Sub-directory appended under a configured base path. */ public static final String UPLOAD_SUBDIR = "chat-uploads"; + /** + * Shape of the per-day sub-directory name inserted under a conversation dir + * when {@link ChatUploadProperties#isDateFolders()} is on. Anchored so read + * paths can distinguish date levels from ordinary sub-directories (e.g. the + * office-preview cache dir) when probing. + */ + private static final Pattern DATE_DIR = Pattern.compile("\\d{4}-\\d{2}-\\d{2}"); + /** * Turn a business conversation id into a filesystem-safe path segment. *

@@ -87,13 +107,111 @@ public class ChatUploadLocationResolver { } /** - * The single conversation attachment directory (write target): - * {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}. + * The conversation's attachment root directory: + * {@code {uploadRoot}/{sanitizeSegment(conversationId)}/}. This is the + * root of the conversation's files — writers must go through + * {@link #resolveWriteDir(String)} instead, which appends the per-day + * sub-directory when date folders are enabled. */ public Path resolveConversationDir(String conversationId) { return resolveUploadRoot(conversationId).resolve(sanitizeSegment(conversationId)); } + /** + * The directory new attachments and generated media must be written into: + * {@code {conversationDir}/yyyy-MM-dd/} when date folders are enabled, else + * the flat {@code {conversationDir}/}. The directory is not created here — + * callers keep their existing {@code Files.createDirectories(dir)}. + */ + public Path resolveWriteDir(String conversationId) { + Path conversationDir = resolveConversationDir(conversationId); + return properties.isDateFolders() + ? conversationDir.resolve(LocalDate.now().toString()) + : conversationDir; + } + + /** + * Directories a read path must scan to see every file of a conversation + * dir: the flat dir itself first (legacy layout + date-folders-off writes), + * then each {@code yyyy-MM-dd} sub-directory, newest first. Static so the + * tool-side resolver (no Spring context) can share the exact probing rule. + * + * @param conversationDir a single conversation attachment root + * @return ordered scan list; just {@code [conversationDir]} when it has no + * date sub-directories (or doesn't exist) + */ + public static List dateScanDirs(Path conversationDir) { + List dirs = new ArrayList<>(); + dirs.add(conversationDir); + if (!Files.isDirectory(conversationDir)) { + return dirs; + } + try (var stream = Files.list(conversationDir)) { + stream.filter(Files::isDirectory) + .filter(p -> DATE_DIR.matcher(p.getFileName().toString()).matches()) + .sorted(Comparator.comparing((Path p) -> p.getFileName().toString()).reversed()) + .forEach(dirs::add); + } catch (IOException e) { + log.warn("[ChatUpload] Failed to list date sub-directories of {}: {}", + conversationDir, e.getMessage()); + } + return dirs; + } + + /** + * Traversal-guarded lookup of a stored file under one conversation dir, + * probing the flat layout first and then each date sub-directory (newest + * first). {@code storedName} must be a bare file name — every write path + * produces one, and anything carrying a path separator, a root, or + * {@code ..} is rejected outright rather than normalized, so no probe can + * leave the scan dir it was resolved against. The root check matters on + * Windows: {@code Path.resolve} discards the base for a rooted argument, so + * a drive-relative name like {@code C:evil.txt} would otherwise escape (it + * is not {@code isAbsolute()}). The {@code startsWith} assertion after + * resolution keeps the guarantee platform-independent. Returns {@code null} + * when absent or rejected. + */ + public static Path findInConversationDir(Path conversationDir, String storedName) { + if (storedName == null || storedName.isBlank()) { + return null; + } + Path fileName; + try { + fileName = Paths.get(storedName); + } catch (InvalidPathException e) { + // Illegal on this filesystem (e.g. '*' or ':' on Windows) — no + // stored file could carry that name here. + return null; + } + if (fileName.getRoot() != null || fileName.getNameCount() != 1 || "..".equals(storedName)) { + return null; + } + Path normDir = conversationDir.normalize(); + for (Path scanDir : dateScanDirs(normDir)) { + Path candidate = scanDir.resolve(fileName); + if (candidate.startsWith(scanDir) && Files.isRegularFile(candidate)) { + return candidate; + } + } + return null; + } + + /** + * Resolve a stored file across every candidate conversation dir + * (workspace-scoped + legacy default, sanitized + raw id) and both layouts + * (flat + date sub-directories). The single entry point for serving / + * download paths; returns {@code null} when no candidate holds the file. + */ + public Path resolveExistingFile(String conversationId, String storedName) { + for (Path conversationDir : resolveCandidateConversationDirs(conversationId)) { + Path found = findInConversationDir(conversationDir, storedName); + if (found != null) { + return found; + } + } + return null; + } + /** * Every conversation attachment directory a read / cleanup path should probe, * ordered: the sanitized dir under each candidate root first, then — for diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index cd3d9082..ed195b34 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -198,6 +198,12 @@ mateclaw: # instead; reads and cleanup still check this default dir so legacy uploads # remain resolvable. Defaults to the legacy location for zero-config parity. base-dir: ${MATECLAW_CHAT_UPLOAD_BASE_DIR:data/chat-uploads} + # Organize new attachments/generated media into per-day sub-directories: + # {convDir}/yyyy-MM-dd/{storedName}. Serving URLs stay flat and reads + # probe both layouts, so this can be toggled at any time; files written + # under the previous layout remain resolvable either way. The day is the + # server's local date (a UTC container groups by UTC days). + date-folders: ${MATECLAW_CHAT_UPLOAD_DATE_FOLDERS:true} skill: upload: # Size caps for skill bundle ZIPs (upload endpoint and marketplace diff --git a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md index 53330b1e..ed03cb16 100644 --- a/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/en/wecom-tuning.md @@ -212,7 +212,7 @@ Fix: `AsyncTaskMediaDispatcher.forwardToImIfBound(conversationId, parts)`: - Slack: via `filesUploadV2` (see [Slack channel](./channels#slack)) - Channels without `sendContentParts` (QQ, etc.): catch UnsupportedOperationException + log; one unsupported channel doesn't block the rest -Files live at `data/chat-uploads/{conversationId}/` by default, but when the conversation's Agent / Workspace has a `basePath` configured, attachments land under `{basePath}/chat-uploads/{conversationId}/` (precedence: Agent `workspaceBasePath` → Workspace `basePath` → default dir `mateclaw.chat.upload.base-dir`). Reads and cleanup probe both the new and legacy locations, so pre-migration attachments stay accessible. Served at `/api/v1/chat/files/{conversationId}/{storedName}`; frontend and channel attachment views all read by this URL. +Files live at `data/chat-uploads/{conversationId}/` by default, but when the conversation's Agent / Workspace has a `basePath` configured, attachments land under `{basePath}/chat-uploads/{conversationId}/` (precedence: Agent `workspaceBasePath` → Workspace `basePath` → default dir `mateclaw.chat.upload.base-dir`). Inside the conversation dir, new files are further grouped into per-day sub-directories by default (`{conversationId}/yyyy-MM-dd/{storedName}`, controlled by `mateclaw.chat.upload.date-folders`; disable to keep the flat layout). Reads and cleanup probe both the new and legacy locations and both layouts (flat + date sub-directories), so pre-migration attachments stay accessible. Served at `/api/v1/chat/files/{conversationId}/{storedName}` — the URL stays flat with no date segment; frontend and channel attachment views all read by this URL. --- diff --git a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md index c71c8e28..ebf445f8 100644 --- a/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md +++ b/mateclaw-server/src/main/resources/docs/zh/wecom-tuning.md @@ -212,7 +212,7 @@ MateClaw 在 link 分支检测到 `mp.weixin.qq.com` 后,会自动给模型追 - Slack:通过 `filesUploadV2` 直传(参考 [Slack channel](./channels#slack)) - 不支持 `sendContentParts` 的渠道(QQ 等):catch UnsupportedOperationException + log,不让一个不支持的渠道卡住整批分发 -文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`(解析优先级:Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。读取与清理会同时探测新旧位置,迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`,前端 / 渠道附件视图都按这个 URL 读。 +文件路径默认在 `data/chat-uploads/{conversationId}/`,但当会话的 Agent / Workspace 配置了 `basePath` 时,附件落在 `{basePath}/chat-uploads/{conversationId}/`(解析优先级:Agent `workspaceBasePath` → Workspace `basePath` → 默认目录 `mateclaw.chat.upload.base-dir`)。会话目录下默认再按天分文件夹(`{conversationId}/yyyy-MM-dd/{storedName}`,由 `mateclaw.chat.upload.date-folders` 控制,可关闭回平铺布局)。读取与清理会同时探测新旧位置及平铺 / 日期两种布局,迁移前的旧附件仍可访问。serve URL 是 `/api/v1/chat/files/{conversationId}/{storedName}`(保持平铺、不含日期段),前端 / 渠道附件视图都按这个 URL 读。 --- diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java index 6b3d8fb9..88db9f93 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPreviewRouteTest.java @@ -100,7 +100,7 @@ class ChatControllerPreviewRouteTest { when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true); when(officePreviewService.isConvertible(STORED)).thenReturn(true); when(officePreviewService.isAvailable()).thenReturn(true); - when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of()); + when(uploadLocationResolver.resolveExistingFile(CONV, STORED)).thenReturn(null); mockMvc.perform(MockMvcRequestBuilders.get(URL).principal(admin())) .andExpect(status().isNotFound()); } @@ -115,7 +115,7 @@ class ChatControllerPreviewRouteTest { when(conversationService.isConversationOwner(eq(CONV), anyString())).thenReturn(true); when(officePreviewService.isConvertible(STORED)).thenReturn(true); when(officePreviewService.isAvailable()).thenReturn(true); - when(uploadLocationResolver.resolveCandidateConversationDirs(CONV)).thenReturn(List.of(dir)); + when(uploadLocationResolver.resolveExistingFile(CONV, STORED)).thenReturn(src); byte[] pdf = "%PDF-1.4 fake".getBytes(); when(officePreviewService.renderPdf(any(Path.class))).thenReturn(pdf); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java index d3020072..d8dc6b9c 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerUploadPathTest.java @@ -16,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; * root became absolute (the resolver normalizes via {@code toAbsolutePath()}), * the {@code path} field — which is rendered into the LLM prompt and returned to * the client — started leaking the server's absolute filesystem layout. These - * tests lock the value back to {@code chat-uploads/{convId}/{storedName}}. + * tests lock the value to {@code chat-uploads/{convId}/[{date}/]{storedName}}. */ class ChatControllerUploadPathTest { @@ -25,21 +25,35 @@ class ChatControllerUploadPathTest { void defaultRootIsRelative() { // Mirrors the resolver's default root: absolute + normalized. Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize(); + Path target = uploadRoot.resolve("conv-1").resolve("1777_a.txt"); - String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-1", "1777_a.txt"); + String path = ChatController.toRelativeUploadPath(uploadRoot, target); assertThat(path).isEqualTo("chat-uploads/conv-1/1777_a.txt"); assertThat(Paths.get(path).isAbsolute()).isFalse(); assertThat(path).doesNotContain(uploadRoot.toString()); } + @Test + @DisplayName("date-folder target: date segment is preserved in the relative path") + void dateFolderTargetKeepsDateSegment() { + Path uploadRoot = Paths.get("data", "chat-uploads").toAbsolutePath().normalize(); + Path target = uploadRoot.resolve("conv-1").resolve("2026-07-26").resolve("1777_a.txt"); + + String path = ChatController.toRelativeUploadPath(uploadRoot, target); + + assertThat(path).isEqualTo("chat-uploads/conv-1/2026-07-26/1777_a.txt"); + assertThat(Paths.get(path).isAbsolute()).isFalse(); + } + @Test @DisplayName("workspace-scoped absolute root: still root-relative, no leak") void scopedRootIsRelative() { // An absolute workspace basePath somewhere outside the CWD. Path uploadRoot = Paths.get("/srv/ws/alpha/chat-uploads").toAbsolutePath().normalize(); + Path target = uploadRoot.resolve("conv-2").resolve("9_b.pdf"); - String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-2", "9_b.pdf"); + String path = ChatController.toRelativeUploadPath(uploadRoot, target); assertThat(path).isEqualTo("chat-uploads/conv-2/9_b.pdf"); assertThat(path).doesNotContain("/srv/ws/alpha"); @@ -49,8 +63,9 @@ class ChatControllerUploadPathTest { @DisplayName("custom base-dir name is preserved (not hardcoded to chat-uploads)") void customBaseDirNamePreserved() { Path uploadRoot = Paths.get("/var/uploads").toAbsolutePath().normalize(); + Path target = uploadRoot.resolve("conv-3").resolve("f.bin"); - String path = ChatController.toRelativeUploadPath(uploadRoot, "conv-3", "f.bin"); + String path = ChatController.toRelativeUploadPath(uploadRoot, target); assertThat(path).isEqualTo("uploads/conv-3/f.bin"); } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/ChatUploadResolverDateFolderTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ChatUploadResolverDateFolderTest.java new file mode 100644 index 00000000..a4b99ae7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/ChatUploadResolverDateFolderTest.java @@ -0,0 +1,110 @@ +package vip.mate.tool.builtin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the tool-side attachment resolver finds files under both the flat + * conversation-dir layout and the per-day ({@code yyyy-MM-dd}) sub-directory + * layout, including the sanitized-basename suffix fallback used when the LLM + * passes the original (non-ASCII) filename instead of the stored name. + */ +class ChatUploadResolverDateFolderTest { + + @TempDir + Path tempDir; + + @AfterEach + void clearContext() { + ToolExecutionContext.clear(); + } + + /** Point the resolver at {@code tempDir} as the workspace base path. */ + private Path conversationDir(String conversationId) throws Exception { + ToolExecutionContext.set(conversationId, "tester", tempDir.toString()); + Path dir = tempDir.resolve("chat-uploads").resolve(conversationId); + Files.createDirectories(dir); + return dir; + } + + @Test + @DisplayName("flat layout: direct basename match still resolves") + void resolvesFlatFile() throws Exception { + Path convDir = conversationDir("conv-flat"); + Files.writeString(convDir.resolve("1777_report.pdf"), "x"); + + assertThat(ChatUploadResolver.resolve("1777_report.pdf")) + .isEqualTo(convDir.resolve("1777_report.pdf")); + } + + @Test + @DisplayName("date layout: file under yyyy-MM-dd resolves by stored name") + void resolvesDatedFile() throws Exception { + Path convDir = conversationDir("conv-dated"); + Path dateDir = convDir.resolve("2026-07-26"); + Files.createDirectories(dateDir); + Files.writeString(dateDir.resolve("1777_report.pdf"), "x"); + + assertThat(ChatUploadResolver.resolve("1777_report.pdf")) + .isEqualTo(dateDir.resolve("1777_report.pdf")); + } + + @Test + @DisplayName("date layout: sanitized-suffix fallback matches original filename") + void resolvesDatedFileBySuffixFallback() throws Exception { + Path convDir = conversationDir("conv-suffix"); + Path dateDir = convDir.resolve("2026-07-26"); + Files.createDirectories(dateDir); + // Stored as "{millis}_{sanitized}": non-ASCII chars become underscores. + Files.writeString(dateDir.resolve("1777391026594_____.docx"), "x"); + + assertThat(ChatUploadResolver.resolve("人人有虾.docx")) + .isEqualTo(dateDir.resolve("1777391026594_____.docx")); + } + + @Test + @DisplayName("suffix fallback: the newest same-named copy wins over a stale flat one") + void suffixFallbackPrefersNewestCopy() throws Exception { + Path convDir = conversationDir("conv-newest"); + Path dateDir = convDir.resolve("2026-07-26"); + Files.createDirectories(dateDir); + Path stale = convDir.resolve("1777000000000_report.docx"); + Path fresh = dateDir.resolve("1777391026594_report.docx"); + Files.writeString(stale, "old"); + Files.writeString(fresh, "new"); + Files.setLastModifiedTime(stale, FileTime.fromMillis(1_777_000_000_000L)); + Files.setLastModifiedTime(fresh, FileTime.fromMillis(1_777_391_026_594L)); + + assertThat(ChatUploadResolver.resolve("report.docx")).isEqualTo(fresh); + } + + @Test + @DisplayName("a Windows-style path from the model still resolves on a POSIX host") + void resolvesWindowsStylePathOnPosixHost() throws Exception { + Path convDir = conversationDir("conv-winpath"); + Path dateDir = convDir.resolve("2026-07-26"); + Files.createDirectories(dateDir); + Files.writeString(dateDir.resolve("1777391026594_report.pdf"), "x"); + + // A backslash is a legal file-name character on Linux/macOS, so the + // basename has to be split on both separators, not just the host's. + assertThat(ChatUploadResolver.resolve("C:\\Users\\me\\report.pdf")) + .isEqualTo(dateDir.resolve("1777391026594_report.pdf")); + } + + @Test + @DisplayName("missing file resolves to null in either layout") + void missingFileIsNull() throws Exception { + conversationDir("conv-missing"); + + assertThat(ChatUploadResolver.resolve("nope.pdf")).isNull(); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java index 214fb224..fbf973d1 100644 --- a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/ChatUploadLocationResolverTest.java @@ -11,7 +11,9 @@ import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.core.config.ChatUploadProperties; import vip.mate.workspace.core.model.WorkspaceEntity; +import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -264,4 +266,128 @@ class ChatUploadLocationResolverTest { assertThat(dirs).containsExactly(tempDir.toAbsolutePath().normalize().resolve("plainconv")); } + + // ==================== date folders ==================== + + private ChatUploadLocationResolver resolver(Path defaultDir, boolean dateFolders) { + ChatUploadProperties props = new ChatUploadProperties(); + props.setBaseDir(defaultDir.toAbsolutePath().toString()); + props.setDateFolders(dateFolders); + return new ChatUploadLocationResolver(conversationMapper, workspaceService, props, agentService); + } + + @Test + @DisplayName("resolveWriteDir: date folders on → {convDir}/{yyyy-MM-dd}") + void writeDirAppendsDateSegmentWhenEnabled() { + stubConversation("c-date", null, null); + when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null)); + + ChatUploadLocationResolver r = resolver(tempDir, true); + Path dir = r.resolveWriteDir("c-date"); + + assertThat(dir).isEqualTo(tempDir.toAbsolutePath().normalize() + .resolve("c-date") + .resolve(LocalDate.now().toString())); + } + + @Test + @DisplayName("resolveWriteDir: date folders off → flat conversation dir") + void writeDirIsFlatWhenDisabled() { + stubConversation("c-flat", null, null); + when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null)); + + ChatUploadLocationResolver r = resolver(tempDir, false); + Path dir = r.resolveWriteDir("c-flat"); + + assertThat(dir).isEqualTo(tempDir.toAbsolutePath().normalize().resolve("c-flat")); + } + + @Test + @DisplayName("dateScanDirs: flat dir first, then date subdirs newest-first; non-date subdirs ignored") + void dateScanDirsOrderedNewestFirst() throws Exception { + Path convDir = tempDir.resolve("c-scan"); + Files.createDirectories(convDir.resolve("2026-07-25")); + Files.createDirectories(convDir.resolve("2026-07-26")); + Files.createDirectories(convDir.resolve("preview")); + + List dirs = ChatUploadLocationResolver.dateScanDirs(convDir); + + assertThat(dirs).containsExactly( + convDir, + convDir.resolve("2026-07-26"), + convDir.resolve("2026-07-25")); + } + + @Test + @DisplayName("findInConversationDir: resolves flat legacy files and date-subdir files") + void findInConversationDirProbesBothLayouts() throws Exception { + Path convDir = tempDir.resolve("c-find"); + Files.createDirectories(convDir.resolve("2026-07-26")); + Files.writeString(convDir.resolve("flat.txt"), "legacy"); + Files.writeString(convDir.resolve("2026-07-26").resolve("dated.txt"), "new"); + + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "flat.txt")) + .isEqualTo(convDir.resolve("flat.txt")); + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "dated.txt")) + .isEqualTo(convDir.resolve("2026-07-26").resolve("dated.txt")); + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "missing.txt")) + .isNull(); + } + + @Test + @DisplayName("findInConversationDir: traversal escaping the conversation dir is rejected") + void findInConversationDirRejectsTraversal() throws Exception { + Path convDir = tempDir.resolve("c-guard"); + Files.createDirectories(convDir); + Files.writeString(tempDir.resolve("outside.txt"), "secret"); + + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "../outside.txt")) + .isNull(); + } + + @Test + @DisplayName("findInConversationDir: a date-subdir probe cannot climb back into the conversation root") + void findInConversationDirRejectsClimbOutOfDateDir() throws Exception { + Path convDir = tempDir.resolve("c-climb"); + Files.createDirectories(convDir.resolve("2026-07-26")); + Files.writeString(convDir.resolve("flat.txt"), "legacy"); + + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "2026-07-26/../flat.txt")) + .isNull(); + } + + @Test + @DisplayName("findInConversationDir: rooted or multi-segment stored names are rejected outright") + void findInConversationDirRejectsNonBareNames() throws Exception { + Path convDir = tempDir.resolve("c-bare"); + Files.createDirectories(convDir.resolve("2026-07-26")); + Files.writeString(convDir.resolve("flat.txt"), "legacy"); + + // Rooted names matter on Windows, where Path.resolve drops the base for + // a rooted argument; rejecting them keeps the guard platform-agnostic. + assertThat(ChatUploadLocationResolver.findInConversationDir( + convDir, tempDir.toAbsolutePath() + "/flat.txt")).isNull(); + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "2026-07-26/flat.txt")) + .isNull(); + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "..")).isNull(); + // The bare name still resolves. + assertThat(ChatUploadLocationResolver.findInConversationDir(convDir, "flat.txt")) + .isEqualTo(convDir.resolve("flat.txt")); + } + + @Test + @DisplayName("resolveExistingFile: end-to-end lookup across candidate dirs and layouts") + void resolveExistingFileFindsDatedFile() throws Exception { + stubConversation("c-e2e", null, null); + when(workspaceService.getById(anyLong())).thenReturn(workspace(1L, null)); + + ChatUploadLocationResolver r = resolver(tempDir, true); + Path writeDir = r.resolveWriteDir("c-e2e"); + Files.createDirectories(writeDir); + Files.writeString(writeDir.resolve("1777_a.png"), "img"); + + assertThat(r.resolveExistingFile("c-e2e", "1777_a.png")) + .isEqualTo(writeDir.resolve("1777_a.png")); + assertThat(r.resolveExistingFile("c-e2e", "nope.png")).isNull(); + } }