mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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
|
// Desktop local-tool tunnel — the handshake interceptor
|
||||||
// authenticates the ?token= query param itself, so the
|
// authenticates the ?token= query param itself, so the
|
||||||
// upgrade request is opened to the filter chain like talk/ws.
|
// upgrade request is opened to the filter chain like talk/ws.
|
||||||
"/api/v1/desktop/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/**"
|
|
||||||
).permitAll();
|
).permitAll();
|
||||||
// Swagger UI / OpenAPI document — explicit rule rather than the
|
// Swagger UI / OpenAPI document — explicit rule rather than the
|
||||||
// permitAll() fallthrough. Public for local dev, admin-only in
|
// 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.
|
// 1. Explicit generated-file id — trust only a live image entry.
|
||||||
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r);
|
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(r);
|
||||||
if (m.find()) {
|
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())) {
|
if (e.isPresent() && isImage(e.get()) && hasBytes(e.get())) {
|
||||||
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx));
|
return new ResolvedCover(e.get().bytes(), cache.downloadUrl(m.group(1), ctx));
|
||||||
}
|
}
|
||||||
@ -306,7 +306,7 @@ public class GzhPackageTool {
|
|||||||
if (name != null && !name.isBlank()) {
|
if (name != null && !name.isBlank()) {
|
||||||
Optional<String> healed = cache.findIdByFilename(name, "image/");
|
Optional<String> healed = cache.findIdByFilename(name, "image/");
|
||||||
if (healed.isPresent()) {
|
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())) {
|
if (e.isPresent() && hasBytes(e.get())) {
|
||||||
log.info("[GzhPackage] cover ref '{}' healed to generated id {} by filename", r, healed.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));
|
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) {
|
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);
|
return cache.downloadUrl(id, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -139,7 +139,7 @@ public class SendFileTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String stash(byte[] bytes, String displayName, String mimeType, @Nullable ToolContext ctx) {
|
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);
|
return cache.downloadUrl(id, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -281,13 +281,14 @@ public class XhsPackageTool {
|
|||||||
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
|
Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(ref);
|
||||||
if (m.find()) {
|
if (m.find()) {
|
||||||
String id = m.group(1);
|
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) {
|
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.
|
// Self-heal: the ref may point at the file's name, not its id.
|
||||||
Optional<String> healed = cache.findIdByFilename(lastSegment(ref), "image/");
|
Optional<String> healed = cache.findIdByFilename(lastSegment(ref), "image/");
|
||||||
if (healed.isPresent()) {
|
if (healed.isPresent()) {
|
||||||
id = healed.get();
|
id = healed.get();
|
||||||
entry = cache.get(id);
|
entry = cache.getForWorkspace(id, workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
|
if (entry.isEmpty() || entry.get().bytes() == null || entry.get().bytes().length == 0) {
|
||||||
@ -311,12 +312,12 @@ public class XhsPackageTool {
|
|||||||
}
|
}
|
||||||
byte[] bytes = Files.readAllBytes(path);
|
byte[] bytes = Files.readAllBytes(path);
|
||||||
String ext = extFromUrl(ref);
|
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));
|
return new ResolvedImg(bytes, ext, cache.downloadUrl(id, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String store(byte[] bytes, String name, String mime, @Nullable ToolContext 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() {
|
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 —
|
* 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
|
* after generation still resolves instead of 404ing. Entries are retained for
|
||||||
* {@link #TTL} and a scheduled sweep removes expired files. The download URL
|
* {@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
|
@Slf4j
|
||||||
@Component
|
@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() {
|
public boolean expired() {
|
||||||
return System.currentTimeMillis() > expireAt;
|
return System.currentTimeMillis() > expireAt;
|
||||||
@ -145,9 +159,20 @@ public class GeneratedFileCache {
|
|||||||
* {@code /api/v1/files/generated/{id}}.
|
* {@code /api/v1/files/generated/{id}}.
|
||||||
*/
|
*/
|
||||||
public String put(byte[] bytes, String filename, String mimeType) {
|
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();
|
String id = UUID.randomUUID().toString();
|
||||||
long expireAt = System.currentTimeMillis() + TTL.toMillis();
|
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);
|
entries.put(id, entry);
|
||||||
persist(id, entry);
|
persist(id, entry);
|
||||||
log.debug("Cached generated file id={} filename={} bytes={}", id, filename,
|
log.debug("Cached generated file id={} filename={} bytes={}", id, filename,
|
||||||
@ -236,6 +261,21 @@ public class GeneratedFileCache {
|
|||||||
return Optional.of(entry);
|
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
|
* 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
|
* constrained to a mime-type prefix (e.g. {@code "image/"}). Scans the
|
||||||
@ -279,20 +319,17 @@ public class GeneratedFileCache {
|
|||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
for (Path metaPath : metas) {
|
for (Path metaPath : metas) {
|
||||||
try {
|
try {
|
||||||
String[] parts = Files.readString(metaPath).split("\t", 3);
|
Metadata meta = parseMeta(Files.readString(metaPath), idFromMetaPath(metaPath));
|
||||||
if (Long.parseLong(parts[0].trim()) <= now) {
|
if (meta.expireAt() <= now) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String mime = parts.length > 1 && !parts[1].isEmpty() ? parts[1] : null;
|
String mime = meta.mimeType();
|
||||||
if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) {
|
if (mimePrefix != null && (mime == null || !mime.startsWith(mimePrefix))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String fn = parts.length > 2 && !parts[2].isEmpty()
|
String fn = meta.filename();
|
||||||
? new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8)
|
|
||||||
: null;
|
|
||||||
if (fn != null && target.equalsIgnoreCase(fn)) {
|
if (fn != null && target.equalsIgnoreCase(fn)) {
|
||||||
String name = metaPath.getFileName().toString();
|
return Optional.of(idFromMetaPath(metaPath));
|
||||||
return Optional.of(name.substring(0, name.length() - META_SUFFIX.length()));
|
|
||||||
}
|
}
|
||||||
} catch (Exception ignore) {
|
} catch (Exception ignore) {
|
||||||
// Skip unreadable / malformed meta.
|
// Skip unreadable / malformed meta.
|
||||||
@ -307,12 +344,15 @@ public class GeneratedFileCache {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Files.write(storageDir.resolve(id), entry.bytes());
|
Files.write(storageDir.resolve(id), entry.bytes());
|
||||||
// expireAt \t mimeType \t base64(filename) — filename is base64-encoded
|
// expireAt \t mimeType \t base64(filename) \t workspaceId
|
||||||
// so arbitrary unicode / separators round-trip without escaping.
|
// \t ownerUserId \t base64(conversationId). Base64 keeps unicode and
|
||||||
|
// separators round-trippable without custom escaping.
|
||||||
String meta = entry.expireAt()
|
String meta = entry.expireAt()
|
||||||
+ "\t" + (entry.mimeType() == null ? "" : entry.mimeType())
|
+ "\t" + (entry.mimeType() == null ? "" : entry.mimeType())
|
||||||
+ "\t" + Base64.getEncoder().encodeToString(
|
+ "\t" + b64(entry.filename())
|
||||||
(entry.filename() == null ? "" : entry.filename()).getBytes(StandardCharsets.UTF_8));
|
+ "\t" + (entry.workspaceId() == null ? "" : entry.workspaceId())
|
||||||
|
+ "\t" + (entry.ownerUserId() == null ? "" : entry.ownerUserId())
|
||||||
|
+ "\t" + b64(entry.conversationId());
|
||||||
Files.writeString(storageDir.resolve(id + META_SUFFIX), meta);
|
Files.writeString(storageDir.resolve(id + META_SUFFIX), meta);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// Best-effort: an in-memory entry still serves the current process.
|
// Best-effort: an in-memory entry still serves the current process.
|
||||||
@ -328,20 +368,54 @@ public class GeneratedFileCache {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String[] parts = Files.readString(meta).split("\t", 3);
|
Metadata parsed = parseMeta(Files.readString(meta), id);
|
||||||
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;
|
|
||||||
byte[] bytes = Files.readAllBytes(bin);
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("Could not load generated file id={}: {}", id, e.toString());
|
log.warn("Could not load generated file id={}: {}", id, e.toString());
|
||||||
return null;
|
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) {
|
private void evict(String id) {
|
||||||
entries.remove(id);
|
entries.remove(id);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -6,10 +6,15 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@ -18,8 +23,8 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* Serves bytes produced by tools and stashed in {@link GeneratedFileCache}.
|
* Serves bytes produced by tools and stashed in {@link GeneratedFileCache}.
|
||||||
*
|
*
|
||||||
* <p>Endpoint is intentionally unauthenticated; the UUID in the URL is the only
|
* <p>Entries expire after {@link GeneratedFileCache#TTL}. Web downloads require
|
||||||
* access credential. Entries expire after {@link GeneratedFileCache#TTL}.
|
* an authenticated caller in the same current workspace as the generated file.
|
||||||
*/
|
*/
|
||||||
@Tag(name = "Generated Files")
|
@Tag(name = "Generated Files")
|
||||||
@RestController
|
@RestController
|
||||||
@ -28,16 +33,27 @@ import java.util.Map;
|
|||||||
public class GeneratedFileController {
|
public class GeneratedFileController {
|
||||||
|
|
||||||
private final GeneratedFileCache cache;
|
private final GeneratedFileCache cache;
|
||||||
|
private final AuthService authService;
|
||||||
|
private final WorkspaceService workspaceService;
|
||||||
|
|
||||||
@Operation(summary = "Download a tool-generated file by its one-time id")
|
@Operation(summary = "Download a tool-generated file by its one-time id")
|
||||||
@GetMapping("/{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)
|
return cache.get(id)
|
||||||
|
.filter(entry -> canDownload(entry, workspaceId, user))
|
||||||
.<ResponseEntity<?>>map(entry -> {
|
.<ResponseEntity<?>>map(entry -> {
|
||||||
String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
|
String encodedName = URLEncoder.encode(entry.filename(), StandardCharsets.UTF_8)
|
||||||
.replace("+", "%20");
|
.replace("+", "%20");
|
||||||
HttpHeaders headers = new HttpHeaders();
|
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));
|
headers.setContentType(MediaType.parseMediaType(mime));
|
||||||
// RFC 5987 filename* lets non-ASCII names round-trip in browsers.
|
// RFC 5987 filename* lets non-ASCII names round-trip in browsers.
|
||||||
// Images and HTML previews render inline; everything else downloads.
|
// Images and HTML previews render inline; everything else downloads.
|
||||||
@ -60,8 +76,30 @@ public class GeneratedFileController {
|
|||||||
headers.setContentLength(entry.bytes().length);
|
headers.setContentLength(entry.bytes().length);
|
||||||
return ResponseEntity.ok().headers(headers).body(entry.bytes());
|
return ResponseEntity.ok().headers(headers).body(entry.bytes());
|
||||||
})
|
})
|
||||||
.orElseGet(() -> ResponseEntity.status(404)
|
.orElseGet(() -> cache.get(id).isPresent()
|
||||||
.body(Map.of("error", "File not found or expired")));
|
? 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) {
|
private String sanitizeAscii(String name) {
|
||||||
|
|||||||
@ -59,7 +59,7 @@ public final class GeneratedFileLink {
|
|||||||
|
|
||||||
private static String stash(byte[] bytes, String displayName, String mimeType,
|
private static String stash(byte[] bytes, String displayName, String mimeType,
|
||||||
GeneratedFileCache cache, @Nullable ToolContext ctx) {
|
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);
|
return cache.downloadUrl(id, ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ public final class WorkspaceArtifactSurfacer {
|
|||||||
byte[] bytes = Files.readAllBytes(p);
|
byte[] bytes = Files.readAllBytes(p);
|
||||||
totalBytes += size;
|
totalBytes += size;
|
||||||
String name = p.getFileName().toString();
|
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) + ")");
|
links.add("[" + name + "](" + cache.downloadUrl(id, ctx) + ")");
|
||||||
} catch (Exception perFile) {
|
} catch (Exception perFile) {
|
||||||
log.debug("[ArtifactSurfacer] skip {}: {}", p, perFile.getMessage());
|
log.debug("[ArtifactSurfacer] skip {}: {}", p, perFile.getMessage());
|
||||||
@ -81,8 +81,7 @@ public final class WorkspaceArtifactSurfacer {
|
|||||||
|
|
||||||
private static boolean modifiedSince(Path p, long sinceMillis) {
|
private static boolean modifiedSince(Path p, long sinceMillis) {
|
||||||
try {
|
try {
|
||||||
// 1s slack absorbs filesystem mtime granularity.
|
return Files.getLastModifiedTime(p).toMillis() >= sinceMillis;
|
||||||
return Files.getLastModifiedTime(p).toMillis() >= sinceMillis - 1000L;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,15 @@ class OpenApiLockedDownAccessTest {
|
|||||||
assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode());
|
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
|
@Test
|
||||||
@DisplayName("A genuinely public endpoint stays reachable when Swagger is locked")
|
@DisplayName("A genuinely public endpoint stays reachable when Swagger is locked")
|
||||||
void publicEndpointStillReachable() {
|
void publicEndpointStillReachable() {
|
||||||
|
|||||||
@ -38,6 +38,26 @@ class GeneratedFileCachePersistenceTest {
|
|||||||
entry.mimeType());
|
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
|
@Test
|
||||||
@DisplayName("unknown id returns empty")
|
@DisplayName("unknown id returns empty")
|
||||||
void unknownIdEmpty(@TempDir Path dir) {
|
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
|
@Test
|
||||||
@DisplayName("Null / non-existent working dir and null cache are safe no-ops")
|
@DisplayName("Null / non-existent working dir and null cache are safe no-ops")
|
||||||
void edgeCasesAreSafe() throws Exception {
|
void edgeCasesAreSafe() throws Exception {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useThemeStore } from '@/stores/useThemeStore'
|
|||||||
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
||||||
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
|
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
|
||||||
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
|
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
|
||||||
|
import { useGlobalGeneratedImageBlob } from '@/composables/useGlobalGeneratedImageBlob'
|
||||||
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
import McConfirmHost from '@/components/common/McConfirmHost.vue'
|
||||||
import FilePreviewDialog from '@/components/chat/preview/FilePreviewDialog.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
|
// 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.
|
// to the backend's 404 JSON, which would otherwise replace the whole SPA.
|
||||||
useGlobalFileDownloadClick()
|
useGlobalFileDownloadClick()
|
||||||
|
useGlobalGeneratedImageBlob()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|||||||
@ -96,6 +96,8 @@ export async function fetchAuthenticatedBlob(fileUrl: string): Promise<Blob> {
|
|||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
if (token) headers.Authorization = `Bearer ${token}`
|
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 })
|
const response = await fetch(fileUrl, { headers })
|
||||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||||
return response.blob()
|
return response.blob()
|
||||||
|
|||||||
@ -58,7 +58,22 @@ export function useGlobalFileDownloadClick() {
|
|||||||
if (src) {
|
if (src) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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
|
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
|
// download-only link. render_html_image / image generation return
|
||||||
// `[cover.png](/api/v1/files/generated/<id>)`; without this the chat only
|
// `[cover.png](/api/v1/files/generated/<id>)`; without this the chat only
|
||||||
// offers a download and the user can never *see* the picture. The
|
// offers a download and the user can never *see* the picture. The
|
||||||
// generated-file endpoint is permitAll, so a same-origin <img src> loads
|
// generated-file endpoint requires auth, so useGlobalGeneratedImageBlob
|
||||||
// without an auth header. Detection is by the link label's extension
|
// swaps the src for an authenticated blob URL after v-html mounts.
|
||||||
// (the URL itself carries only a UUID). Clicking the image opens it
|
// Detection is by the link label's extension (the URL itself carries only
|
||||||
// full-size in a new tab (see useGlobalFileDownloadClick).
|
// a UUID). Clicking the image opens it full-size in a new tab.
|
||||||
const labelText = innerHtml.replace(/<[^>]*>/g, '').trim()
|
const labelText = innerHtml.replace(/<[^>]*>/g, '').trim()
|
||||||
const isFileApi = /^\/api\/v1\/(files|chat\/files)\//.test(safeHref)
|
const isFileApi = /^\/api\/v1\/(files|chat\/files)\//.test(safeHref)
|
||||||
if (isFileApi && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(labelText)) {
|
if (isFileApi && /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(labelText)) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user