fix(files): restrict generated downloads by workspace (#611)

This commit is contained in:
matevip 2026-08-21 06:06:59 -04:00
parent bdd51e7b44
commit 8d32a60fdb
17 changed files with 351 additions and 50 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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() {

View File

@ -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 {

View File

@ -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) {

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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()

View File

@ -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()

View File

@ -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
}
}

View 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()
})
}

View File

@ -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)) {