mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(files): restrict generated downloads by workspace (#611)
This commit is contained in:
parent
bdd51e7b44
commit
8d32a60fdb
@ -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
|
||||
|
||||
@ -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<GeneratedFileCache.Entry> e = cache.get(m.group(1));
|
||||
Optional<GeneratedFileCache.Entry> 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<String> healed = cache.findIdByFilename(name, "image/");
|
||||
if (healed.isPresent()) {
|
||||
Optional<GeneratedFileCache.Entry> e = cache.get(healed.get());
|
||||
Optional<GeneratedFileCache.Entry> 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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -281,13 +281,14 @@ public class XhsPackageTool {
|
||||
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
|
||||
if (m.find()) {
|
||||
String id = m.group(1);
|
||||
Optional<GeneratedFileCache.Entry> entry = cache.get(id);
|
||||
Long workspaceId = workspaceFromContext(ctx);
|
||||
Optional<GeneratedFileCache.Entry> 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<String> 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() {
|
||||
|
||||
@ -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<Entry> getForWorkspace(String id, @Nullable Long workspaceId) {
|
||||
Optional<Entry> 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 {
|
||||
|
||||
@ -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}.
|
||||
*
|
||||
* <p>Endpoint is intentionally unauthenticated; the UUID in the URL is the only
|
||||
* access credential. Entries expire after {@link GeneratedFileCache#TTL}.
|
||||
* <p>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))
|
||||
.<ResponseEntity<?>>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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -48,6 +48,15 @@ class OpenApiLockedDownAccessTest {
|
||||
assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Anonymous generated-file download is blocked (401)")
|
||||
void anonymousGeneratedFileDownloadBlocked() {
|
||||
ResponseEntity<String> 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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<String> 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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -96,6 +96,8 @@ export async function fetchAuthenticatedBlob(fileUrl: string): Promise<Blob> {
|
||||
const token = localStorage.getItem('token')
|
||||
const headers: Record<string, string> = {}
|
||||
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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
65
mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts
Normal file
65
mateclaw-ui/src/composables/useGlobalGeneratedImageBlob.ts
Normal file
@ -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<string>()
|
||||
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<HTMLImageElement>('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()
|
||||
})
|
||||
}
|
||||
@ -442,10 +442,10 @@ const customRenderer = {
|
||||
// download-only link. render_html_image / image generation return
|
||||
// `[cover.png](/api/v1/files/generated/<id>)`; 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 <img src> 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)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user