mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: v2.0.0 dev + 落盘文件按天分目录 (#554)
- 附件 / 生成媒体写入 {conversationDir}/yyyy-MM-dd/{storedName},serve URL 保持平铺
- 读取路径同时探测平铺与日期两种布局,可随时开关 mateclaw.chat.upload.date-folders
- 读取侧强制 storedName 为纯文件名;原始文件名回退取最近修改的匹配
- 修正 Linux / macOS / Windows 的路径与时间戳精度差异
This commit is contained in:
parent
bdfa8a6f9d
commit
7fb6ba7461
@ -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
|
||||
|
||||
@ -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<RecentFileEntry> 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;
|
||||
}
|
||||
|
||||
@ -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('\\', '/');
|
||||
|
||||
@ -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<Path> 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<Path> files = Files.list(dir)) {
|
||||
for (Path p : (Iterable<Path>) files::iterator) {
|
||||
if (Files.isRegularFile(p)) {
|
||||
count++;
|
||||
total += Files.size(p);
|
||||
if (Files.isDirectory(conversationDir)) {
|
||||
try (Stream<Path> files = Files.walk(conversationDir)) {
|
||||
for (Path p : (Iterable<Path>) files::iterator) {
|
||||
if (Files.isRegularFile(p)) {
|
||||
count++;
|
||||
total += Files.size(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<Path> 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.
|
||||
* <p>
|
||||
* 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<Path> 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<Path>) 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
|
||||
@ -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)}.
|
||||
*
|
||||
* <h3>Date folders</h3>
|
||||
* 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)}.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>
|
||||
@ -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
|
||||
* <em>root</em> 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<Path> dateScanDirs(Path conversationDir) {
|
||||
List<Path> 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 读。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<Path> 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();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user