From 8d32a60fdbf71258d293c3176296e1c04b6e7472 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 21 Aug 2026 06:06:59 -0400 Subject: [PATCH] fix(files): restrict generated downloads by workspace (#611) --- .../java/vip/mate/config/SecurityConfig.java | 6 +- .../vip/mate/tool/builtin/GzhPackageTool.java | 6 +- .../vip/mate/tool/builtin/SendFileTool.java | 2 +- .../vip/mate/tool/builtin/XhsPackageTool.java | 9 +- .../tool/document/GeneratedFileCache.java | 118 ++++++++++++++---- .../document/GeneratedFileController.java | 50 +++++++- .../mate/tool/document/GeneratedFileLink.java | 2 +- .../document/WorkspaceArtifactSurfacer.java | 5 +- .../config/OpenApiLockedDownAccessTest.java | 9 ++ .../GeneratedFileCachePersistenceTest.java | 20 +++ .../document/GeneratedFileControllerTest.java | 64 ++++++++++ .../WorkspaceArtifactSurfacerTest.java | 16 +++ mateclaw-ui/src/App.vue | 2 + mateclaw-ui/src/api/index.ts | 2 + .../composables/useGlobalFileDownloadClick.ts | 17 ++- .../useGlobalGeneratedImageBlob.ts | 65 ++++++++++ .../src/composables/useMarkdownRenderer.ts | 8 +- 17 files changed, 351 insertions(+), 50 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java create mode 100644 mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index cea7dfb2..b310ee38 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -106,11 +106,7 @@ public class SecurityConfig { // Desktop local-tool tunnel — the handshake interceptor // authenticates the ?token= query param itself, so the // upgrade request is opened to the filter chain like talk/ws. - "/api/v1/desktop/ws", - // RFC-045: tool-generated files served via unguessable UUID; entries - // expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an - // IM-delivered link opened later) is intentional, the UUID is the guard. - "/api/v1/files/generated/**" + "/api/v1/desktop/ws" ).permitAll(); // Swagger UI / OpenAPI document — explicit rule rather than the // permitAll() fallthrough. Public for local dev, admin-only in diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java index c59c8873..b9faa3bb 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GzhPackageTool.java @@ -295,7 +295,7 @@ public class GzhPackageTool { // 1. Explicit generated-file id — trust only a live image entry. Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r); if (m.find()) { - Optional e = cache.get(m.group(1)); + Optional e = cache.getForWorkspace(m.group(1), workspaceFromContext(ctx)); if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) { return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx)); } @@ -306,7 +306,7 @@ public class GzhPackageTool { if (name != null && !name.isBlank()) { Optional healed = cache.findIdByFilename(name, "image/"); if (healed.isPresent()) { - Optional e = cache.get(healed.get()); + Optional e = cache.getForWorkspace(healed.get(), workspaceFromContext(ctx)); if (e.isPresent() && hasBytes(e.get())) { log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.get()); return new ResolvedCover(e.get().bytes(), cache.downloadUrl(healed.get(), ctx)); @@ -379,7 +379,7 @@ public class GzhPackageTool { } private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) { - String id = cache.put(bytes, name, mime); + String id = cache.put(bytes, name, mime, ctx); return cache.downloadUrl(id, ctx); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java index 9c2d437a..0bba3157 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -139,7 +139,7 @@ public class SendFileTool { } private String stash(byte[] bytes, String displayName, String mimeType, @Nullable ToolContext ctx) { - String id = cache.put(bytes, displayName, mimeType); + String id = cache.put(bytes, displayName, mimeType, ctx); return cache.downloadUrl(id, ctx); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java index 31bde690..079859b0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/XhsPackageTool.java @@ -281,13 +281,14 @@ public class XhsPackageTool { Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref); if (m.find()) { String id = m.group(1); - Optional entry = cache.get(id); + Long workspaceId = workspaceFromContext(ctx); + Optional entry = cache.getForWorkspace(id, workspaceId); if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) { // Self-heal: the ref may point at the file's name, not its id. Optional healed = cache.findIdByFilename(lastSegment(ref), "image/"); if (healed.isPresent()) { id = healed.get(); - entry = cache.get(id); + entry = cache.getForWorkspace(id, workspaceId); } } if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) { @@ -311,12 +312,12 @@ public class XhsPackageTool { } byte[] bytes = Files.readAllBytes(path); String ext = extFromUrl(ref); - String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext)); + String id = cache.put(bytes, path.getFileName().toString(), mimeFromExt(ext), ctx); return new ResolvedImg(bytes, ext, cache.downloadUrl(id, ctx)); } private String store(byte[] bytes, String name, String mime, @Nullable ToolContext ctx) { - return cache.downloadUrl(cache.put(bytes, name, mime), ctx); + return cache.downloadUrl(cache.put(bytes, name, mime, ctx), ctx); } private String guideText() { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java index e5fa9c2b..7f5ea3b5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileCache.java @@ -37,7 +37,8 @@ import java.util.stream.Stream; * cache eviction and a JVM restart, so a link a user clicks minutes — or days — * after generation still resolves instead of 404ing. Entries are retained for * {@link #TTL} and a scheduled sweep removes expired files. The download URL - * embeds a random {@link UUID}, which acts as the only access credential. + * embeds a random {@link UUID}; web downloads additionally verify the stored + * workspace owner so links cannot cross workspace boundaries. */ @Slf4j @Component @@ -132,7 +133,20 @@ public class GeneratedFileCache { } } - public record Entry(byte[] bytes, String filename, String mimeType, long expireAt) { + public record Owner(@Nullable Long workspaceId, + @Nullable Long ownerUserId, + @Nullable String conversationId) { + + public static Owner from(@Nullable ToolContext ctx) { + ChatOrigin origin = ChatOrigin.from(ctx); + return new Owner(origin.workspaceId(), origin.requesterUserId(), origin.conversationId()); + } + } + + public record Entry(byte[] bytes, String filename, String mimeType, long expireAt, + @Nullable Long workspaceId, + @Nullable Long ownerUserId, + @Nullable String conversationId) { public boolean expired() { return System.currentTimeMillis() > expireAt; @@ -145,9 +159,20 @@ public class GeneratedFileCache { * {@code /api/v1/files/generated/{id}}. */ public String put(byte[] bytes, String filename, String mimeType) { + return put(bytes, filename, mimeType, (Owner) null); + } + + public String put(byte[] bytes, String filename, String mimeType, @Nullable ToolContext ctx) { + return put(bytes, filename, mimeType, Owner.from(ctx)); + } + + public String put(byte[] bytes, String filename, String mimeType, @Nullable Owner owner) { String id = UUID.randomUUID().toString(); long expireAt = System.currentTimeMillis() + TTL.toMillis(); - Entry entry = new Entry(bytes, filename, mimeType, expireAt); + Entry entry = new Entry(bytes, filename, mimeType, expireAt, + owner != null ? owner.workspaceId() : null, + owner != null ? owner.ownerUserId() : null, + owner != null ? owner.conversationId() : null); entries.put(id, entry); persist(id, entry); log.debug("Cached generated file id={} filename={} bytes={}", id, filename, @@ -236,6 +261,21 @@ public class GeneratedFileCache { return Optional.of(entry); } + public Optional getForWorkspace(String id, @Nullable Long workspaceId) { + Optional entry = get(id); + if (entry.isEmpty()) { + return Optional.empty(); + } + Long ownerWorkspaceId = entry.get().workspaceId(); + if (ownerWorkspaceId == null) { + return entry; + } + if (workspaceId == null || !ownerWorkspaceId.equals(workspaceId)) { + return Optional.empty(); + } + return entry; + } + /** * Best-effort lookup of a live entry's id by its logical filename, optionally * constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the @@ -279,20 +319,17 @@ public class GeneratedFileCache { long now = System.currentTimeMillis(); for (Path metaPath : metas) { try { - String[] parts = Files.readString(metaPath).split("\t", 3); - if (Long.parseLong(parts[0].trim()) <= now) { + Metadata meta = parseMeta(Files.readString(metaPath), idFromMetaPath(metaPath)); + if (meta.expireAt() <= now) { continue; } - String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; + String mime = meta.mimeType(); if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) { continue; } - String fn = parts.length > 2 && !parts[2].isEmpty() - ? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8) - : null; + String fn = meta.filename(); if (fn != null && target.equalsIgnoreCase(fn)) { - String name = metaPath.getFileName().toString(); - return Optional.of(name.substring(0, name.length() - META_SUFFIX.length())); + return Optional.of(idFromMetaPath(metaPath)); } } catch (Exception ignore) { // Skip unreadable / malformed meta. @@ -307,12 +344,15 @@ public class GeneratedFileCache { } try { Files.write(storageDir.resolve(id), entry.bytes()); - // expireAt \t mimeType \t base64(filename) — filename is base64-encoded - // so arbitrary unicode / separators round-trip without escaping. + // expireAt \t mimeType \t base64(filename) \t workspaceId + // \t ownerUserId \t base64(conversationId). Base64 keeps unicode and + // separators round-trippable without custom escaping. String meta = entry.expireAt() + "\t" + (entry.mimeType() == null ? "" : entry.mimeType()) - + "\t" + Base64.getEncoder().encodeToString( - (entry.filename() == null ? "" : entry.filename()).getBytes(StandardCharsets.UTF_8)); + + "\t" + b64(entry.filename()) + + "\t" + (entry.workspaceId() == null ? "" : entry.workspaceId()) + + "\t" + (entry.ownerUserId() == null ? "" : entry.ownerUserId()) + + "\t" + b64(entry.conversationId()); Files.writeString(storageDir.resolve(id + META_SUFFIX), meta); } catch (IOException e) { // Best-effort: an in-memory entry still serves the current process. @@ -328,20 +368,54 @@ public class GeneratedFileCache { return null; } try { - String[] parts = Files.readString(meta).split("\t", 3); - long expireAt = Long.parseLong(parts[0].trim()); - String mimeType = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; - String filename = parts.length > 2 && !parts[2].isEmpty() - ? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8) - : id; + Metadata parsed = parseMeta(Files.readString(meta), id); byte[] bytes = Files.readAllBytes(bin); - return new Entry(bytes, filename, mimeType, expireAt); + return new Entry(bytes, parsed.filename(), parsed.mimeType(), parsed.expireAt(), + parsed.workspaceId(), parsed.ownerUserId(), parsed.conversationId()); } catch (Exception e) { log.warn("Could not load generated file id={}: {}", id, e.toString()); return null; } } + private record Metadata(long expireAt, @Nullable String mimeType, String filename, + @Nullable Long workspaceId, @Nullable Long ownerUserId, + @Nullable String conversationId) {} + + private static Metadata parseMeta(String raw, String fallbackFilename) { + String[] parts = raw.split("\t", -1); + long expireAt = Long.parseLong(parts[0].trim()); + String mimeType = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null; + String filename = parts.length > 2 && !parts[2].isEmpty() ? fromB64(parts[2]) : fallbackFilename; + Long workspaceId = parts.length > 3 ? parseLongOrNull(parts[3]) : null; + Long ownerUserId = parts.length > 4 ? parseLongOrNull(parts[4]) : null; + String conversationId = parts.length > 5 && !parts[5].isEmpty() ? fromB64(parts[5]) : null; + return new Metadata(expireAt, mimeType, filename, workspaceId, ownerUserId, conversationId); + } + + private static Long parseLongOrNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Long.parseLong(value.trim()); + } + + private static String b64(@Nullable String value) { + if (value == null || value.isEmpty()) { + return ""; + } + return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String fromB64(String value) { + return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + } + + private static String idFromMetaPath(Path metaPath) { + String name = metaPath.getFileName().toString(); + return name.substring(0, name.length() - META_SUFFIX.length()); + } + private void evict(String id) { entries.remove(id); try { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index 9b1608cc..f7546eef 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -6,10 +6,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -18,8 +23,8 @@ import java.util.Map; /** * Serves bytes produced by tools and stashed in {@link GeneratedFileCache}. * - *

Endpoint is intentionally unauthenticated; the UUID in the URL is the only - * access credential. Entries expire after {@link GeneratedFileCache#TTL}. + *

Entries expire after {@link GeneratedFileCache#TTL}. Web downloads require + * an authenticated caller in the same current workspace as the generated file. */ @Tag(name = "Generated Files") @RestController @@ -28,16 +33,27 @@ import java.util.Map; public class GeneratedFileController { private final GeneratedFileCache cache; + private final AuthService authService; + private final WorkspaceService workspaceService; @Operation(summary = "Download a tool-generated file by its one-time id") @GetMapping("/{id}") - public ResponseEntity download(@PathVariable String id) { + public ResponseEntity download(@PathVariable String id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + Authentication authentication) { + UserEntity user = resolveUser(authentication); + if (user == null) { + return ResponseEntity.status(401).body(Map.of("error", "Unauthorized")); + } return cache.get(id) + .filter(entry -> canDownload(entry, workspaceId, user)) .>map(entry -> { String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8) .replace("+", "%20"); HttpHeaders headers = new HttpHeaders(); - String mime = entry.mimeType(); + String mime = entry.mimeType() == null || entry.mimeType().isBlank() + ? "application/octet-stream" + : entry.mimeType(); headers.setContentType(MediaType.parseMediaType(mime)); // RFC 5987 filename* lets non-ASCII names round-trip in browsers. // Images and HTML previews render inline; everything else downloads. @@ -60,8 +76,30 @@ public class GeneratedFileController { headers.setContentLength(entry.bytes().length); return ResponseEntity.ok().headers(headers).body(entry.bytes()); }) - .orElseGet(() -> ResponseEntity.status(404) - .body(Map.of("error", "File not found or expired"))); + .orElseGet(() -> cache.get(id).isPresent() + ? ResponseEntity.status(403).body(Map.of("error", "Workspace permission denied")) + : ResponseEntity.status(404).body(Map.of("error", "File not found or expired"))); + } + + private UserEntity resolveUser(Authentication authentication) { + if (authentication == null || authentication.getName() == null) { + return null; + } + return authService.findByUsername(authentication.getName()); + } + + private boolean canDownload(GeneratedFileCache.Entry entry, Long currentWorkspaceId, UserEntity user) { + Long ownerWorkspaceId = entry.workspaceId(); + if (ownerWorkspaceId == null) { + return true; + } + if (currentWorkspaceId == null || !ownerWorkspaceId.equals(currentWorkspaceId)) { + return false; + } + if ("admin".equalsIgnoreCase(user.getRole())) { + return true; + } + return workspaceService.hasPermissionCached(ownerWorkspaceId, user.getId(), "viewer"); } private String sanitizeAscii(String name) { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java index a57eec2f..90ae770e 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileLink.java @@ -59,7 +59,7 @@ public final class GeneratedFileLink { private static String stash(byte[] bytes, String displayName, String mimeType, GeneratedFileCache cache, @Nullable ToolContext ctx) { - String id = cache.put(bytes, displayName, mimeType); + String id = cache.put(bytes, displayName, mimeType, ctx); return cache.downloadUrl(id, ctx); } } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java index 9e679aa1..453dfa49 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/WorkspaceArtifactSurfacer.java @@ -67,7 +67,7 @@ public final class WorkspaceArtifactSurfacer { byte[] bytes = Files.readAllBytes(p); totalBytes += size; String name = p.getFileName().toString(); - String id = cache.put(bytes, name, probeMime(p, name)); + String id = cache.put(bytes, name, probeMime(p, name), ctx); links.add("[" + name + "](" + cache.downloadUrl(id, ctx) + ")"); } catch (Exception perFile) { log.debug("[ArtifactSurfacer] skip {}: {}", p, perFile.getMessage()); @@ -81,8 +81,7 @@ public final class WorkspaceArtifactSurfacer { private static boolean modifiedSince(Path p, long sinceMillis) { try { - // 1s slack absorbs filesystem mtime granularity. - return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L; + return Files.getLastModifiedTime(p).toMillis() >= sinceMillis; } catch (Exception e) { return false; } diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java index 2ffce683..1691bc3d 100644 --- a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java +++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java @@ -48,6 +48,15 @@ class OpenApiLockedDownAccessTest { assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); } + @Test + @DisplayName("Anonymous generated-file download is blocked (401)") + void anonymousGeneratedFileDownloadBlocked() { + ResponseEntity resp = rest.getForEntity( + "/api/v1/files/generated/00000000-0000-0000-0000-000000000000", + String.class); + assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode()); + } + @Test @DisplayName("A genuinely public endpoint stays reachable when Swagger is locked") void publicEndpointStillReachable() { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java index f929f1d9..73085d24 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileCachePersistenceTest.java @@ -38,6 +38,26 @@ class GeneratedFileCachePersistenceTest { entry.mimeType()); } + @Test + @DisplayName("workspace ownership metadata persists and gates lookup") + void ownershipPersistsAndGatesLookup(@TempDir Path dir) { + GeneratedFileCache first = new GeneratedFileCache(dir); + byte[] bytes = "workspace-b".getBytes(StandardCharsets.UTF_8); + String id = first.put(bytes, "b.csv", "text/csv", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + + GeneratedFileCache afterRestart = new GeneratedFileCache(dir); + GeneratedFileCache.Entry entry = afterRestart.get(id).orElse(null); + + assertNotNull(entry, "persisted entry must be reloaded from disk after restart"); + assertEquals(20L, entry.workspaceId()); + assertEquals(30L, entry.ownerUserId()); + assertEquals("conv-b", entry.conversationId()); + assertTrue(afterRestart.getForWorkspace(id, 20L).isPresent()); + assertTrue(afterRestart.getForWorkspace(id, 10L).isEmpty(), + "a file generated in workspace B must not resolve under workspace A"); + } + @Test @DisplayName("unknown id returns empty") void unknownIdEmpty(@TempDir Path dir) { diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java new file mode 100644 index 00000000..89b326fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/GeneratedFileControllerTest.java @@ -0,0 +1,64 @@ +package vip.mate.tool.document; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.TestingAuthenticationToken; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GeneratedFileControllerTest { + + @Test + @DisplayName("download is forbidden when current workspace does not match file workspace") + void forbiddenWhenWorkspaceDoesNotMatch(@TempDir Path dir) { + GeneratedFileCache cache = new GeneratedFileCache(dir); + String id = cache.put("secret".getBytes(StandardCharsets.UTF_8), "b.txt", "text/plain", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + AuthService authService = mock(AuthService.class); + WorkspaceService workspaceService = mock(WorkspaceService.class); + when(authService.findByUsername("alice")).thenReturn(user(30L, "user")); + + GeneratedFileController controller = new GeneratedFileController(cache, authService, workspaceService); + ResponseEntity response = controller.download(id, 10L, + new TestingAuthenticationToken("alice", "pw")); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + @DisplayName("download succeeds when current workspace matches and user can view it") + void allowedWhenWorkspaceMatches(@TempDir Path dir) { + GeneratedFileCache cache = new GeneratedFileCache(dir); + String id = cache.put("ok".getBytes(StandardCharsets.UTF_8), "b.txt", "text/plain", + new GeneratedFileCache.Owner(20L, 30L, "conv-b")); + AuthService authService = mock(AuthService.class); + WorkspaceService workspaceService = mock(WorkspaceService.class); + when(authService.findByUsername("alice")).thenReturn(user(30L, "user")); + when(workspaceService.hasPermissionCached(20L, 30L, "viewer")).thenReturn(true); + + GeneratedFileController controller = new GeneratedFileController(cache, authService, workspaceService); + ResponseEntity response = controller.download(id, 20L, + new TestingAuthenticationToken("alice", "pw")); + + assertEquals(200, response.getStatusCode().value()); + } + + private static UserEntity user(Long id, String role) { + UserEntity user = new UserEntity(); + user.setId(id); + user.setUsername("alice"); + user.setRole(role); + user.setEnabled(true); + return user; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java index 66934476..4eec1954 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/document/WorkspaceArtifactSurfacerTest.java @@ -80,6 +80,22 @@ class WorkspaceArtifactSurfacerTest { } } + @Test + @DisplayName("Files modified before the run do not surface even when mtime is close to run start") + void ignoresFilesModifiedBeforeRunStart() throws Exception { + tmp = Files.createTempDirectory("artifacts-"); + cacheDir = Files.createTempDirectory("cache-"); + GeneratedFileCache cache = new GeneratedFileCache(cacheDir); + + long runStart = System.currentTimeMillis(); + Path foreign = Files.write(tmp.resolve("foreign.csv"), "tenant,secret\nb,1\n".getBytes()); + Files.setLastModifiedTime(foreign, FileTime.fromMillis(runStart - 500L)); + + List links = WorkspaceArtifactSurfacer.collect(cache, tmp, runStart, null); + + assertTrue(links.isEmpty(), "pre-existing files from another run/workspace must not surface: " + links); + } + @Test @DisplayName("Null / non-existent working dir and null cache are safe no-ops") void edgeCasesAreSafe() throws Exception { diff --git a/mateclaw-ui/src/App.vue b/mateclaw-ui/src/App.vue index 7e645364..c6281b04 100644 --- a/mateclaw-ui/src/App.vue +++ b/mateclaw-ui/src/App.vue @@ -21,6 +21,7 @@ import { useThemeStore } from '@/stores/useThemeStore' import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick' +import { useGlobalGeneratedImageBlob } from '@/composables/useGlobalGeneratedImageBlob' import McConfirmHost from '@/components/common/McConfirmHost.vue' import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.vue' @@ -42,6 +43,7 @@ useGlobalWikilinkClick() // expired/missing file degrades to a toast instead of a full-page navigation // to the backend's 404 JSON, which would otherwise replace the whole SPA. useGlobalFileDownloadClick() +useGlobalGeneratedImageBlob() const { t } = useI18n() diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f406a3e4..dffb395e 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -96,6 +96,8 @@ export async function fetchAuthenticatedBlob(fileUrl: string): Promise { const token = localStorage.getItem('token') const headers: Record = {} if (token) headers.Authorization = `Bearer ${token}` + const workspaceId = localStorage.getItem('mc-workspace-id') + if (workspaceId) headers['X-Workspace-Id'] = workspaceId const response = await fetch(fileUrl, { headers }) if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) return response.blob() diff --git a/mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts b/mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts index 725ea4c7..efdb53b9 100644 --- a/mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts +++ b/mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts @@ -58,7 +58,22 @@ export function useGlobalFileDownloadClick() { if (src) { e.preventDefault() e.stopPropagation() - window.open(src, '_blank', 'noopener,noreferrer') + if (src.startsWith('blob:')) { + window.open(src, '_blank', 'noopener,noreferrer') + return + } + const win = window.open('about:blank', '_blank', 'noopener,noreferrer') + if (!win) return + try { + const original = genImg.dataset.generatedSrc || src + const url = new URL(original, window.location.href) + const blob = await fetchAuthenticatedBlob(url.pathname + url.search) + const objectUrl = URL.createObjectURL(blob) + win.location.href = objectUrl + setTimeout(() => URL.revokeObjectURL(objectUrl), 300000) + } catch { + win.close() + } return } } diff --git a/mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts b/mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts new file mode 100644 index 00000000..afa3aab1 --- /dev/null +++ b/mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts @@ -0,0 +1,65 @@ +import { onBeforeUnmount, onMounted } from 'vue' +import { fetchAuthenticatedBlob } from '@/api/index' + +const GENERATED_IMAGE_RE = /^\/api\/v1\/files\/generated\// + +export function useGlobalGeneratedImageBlob() { + const objectUrls = new Set() + let observer: MutationObserver | null = null + + function relativeFilePath(src: string): string | null { + try { + const url = new URL(src, window.location.href) + if (!GENERATED_IMAGE_RE.test(url.pathname)) return null + return url.pathname + url.search + } catch { + return null + } + } + + async function loadImage(img: HTMLImageElement) { + if (img.dataset.generatedImageLoaded === '1' || img.dataset.generatedImageLoading === '1') return + const original = img.dataset.generatedSrc || relativeFilePath(img.getAttribute('src') || '') + if (!original) return + img.dataset.generatedSrc = original + img.dataset.generatedImageLoading = '1' + try { + const blob = await fetchAuthenticatedBlob(original) + const objectUrl = URL.createObjectURL(blob) + objectUrls.add(objectUrl) + img.src = objectUrl + img.dataset.generatedImageLoaded = '1' + } catch (e) { + console.warn('[useGlobalGeneratedImageBlob] Failed to load generated image:', original, e) + } finally { + delete img.dataset.generatedImageLoading + } + } + + function scan(root: ParentNode = document) { + root.querySelectorAll('img[data-generated-image]').forEach(loadImage) + } + + onMounted(() => { + scan() + observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLImageElement && node.matches('img[data-generated-image]')) { + loadImage(node) + } else if (node instanceof HTMLElement) { + scan(node) + } + }) + } + }) + observer.observe(document.body, { childList: true, subtree: true }) + }) + + onBeforeUnmount(() => { + observer?.disconnect() + observer = null + objectUrls.forEach((url) => URL.revokeObjectURL(url)) + objectUrls.clear() + }) +} diff --git a/mateclaw-ui/src/composables/useMarkdownRenderer.ts b/mateclaw-ui/src/composables/useMarkdownRenderer.ts index 16fbba44..e2364ff8 100644 --- a/mateclaw-ui/src/composables/useMarkdownRenderer.ts +++ b/mateclaw-ui/src/composables/useMarkdownRenderer.ts @@ -442,10 +442,10 @@ const customRenderer = { // download-only link. render_html_image / image generation return // `[cover.png](/api/v1/files/generated/)`; without this the chat only // offers a download and the user can never *see* the picture. The - // generated-file endpoint is permitAll, so a same-origin loads - // without an auth header. Detection is by the link label's extension - // (the URL itself carries only a UUID). Clicking the image opens it - // full-size in a new tab (see useGlobalFileDownloadClick). + // generated-file endpoint requires auth, so useGlobalGeneratedImageBlob + // swaps the src for an authenticated blob URL after v-html mounts. + // Detection is by the link label's extension (the URL itself carries only + // a UUID). Clicking the image opens it full-size in a new tab. const labelText = innerHtml.replace(/<[^>]*>/g, '').trim() const isFileApi = /^\/api\/v1\/(files|chat\/files)\//.test(safeHref) if (isFileApi && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(labelText)) {