From fa97edbec902de0752ccf15830a44a1dade388c1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 20 Jun 2026 20:12:45 -0700 Subject: [PATCH] fix(files): render embedded workspace images in markdown --- apps/sim/app/api/files/view/[id]/route.ts | 36 +++++++++++-- .../tools/server/files/edit-content.ts | 7 ++- .../tools/server/files/embedded-image-refs.ts | 51 +++++++++++++++++++ .../tools/server/files/workspace-file.ts | 5 +- 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts diff --git a/apps/sim/app/api/files/view/[id]/route.ts b/apps/sim/app/api/files/view/[id]/route.ts index 681d00f4430..e9d68b86821 100644 --- a/apps/sim/app/api/files/view/[id]/route.ts +++ b/apps/sim/app/api/files/view/[id]/route.ts @@ -5,7 +5,7 @@ import { fileViewContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { USE_BLOB_STORAGE } from '@/lib/uploads/config' +import { type StorageContext, USE_BLOB_STORAGE } from '@/lib/uploads/config' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -29,16 +29,44 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - const hasAccess = await verifyFileAccess(record.key, authResult.userId) + // Authorize before disclosing anything about the file. Pass the record's own context so access is + // resolved from the DB row (workspace and mothership both map to workspace membership) rather than + // re-inferred from the key, and deny with 404 so a caller without access cannot distinguish a + // file's existence or context from a missing id. + const hasAccess = await verifyFileAccess( + record.key, + authResult.userId, + undefined, + record.context as StorageContext | 'general' + ) if (!hasAccess) { logger.warn('Unauthorized file view attempt', { id, userId: authResult.userId }) - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // Only workspace-scoped files are embeddable/viewable here. Other contexts (e.g. chat-scoped + // `mothership` uploads) are not durable workspace artifacts; now that the caller is known to have + // access, reject with a legible 422 so the embed fails cleanly and the file agent can self-correct. + if (record.context !== 'workspace') { + logger.warn('Rejected non-workspace file view', { id, context: record.context }) + return NextResponse.json( + { + error: `File ${id} has context "${record.context}" and is not embeddable. Only workspace files can be viewed via /api/files/view. Save it into the workspace and reference the workspace copy.`, + }, + { status: 422 } + ) } const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3' const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}` logger.info('Redirecting file view to serve path', { id, servePath }) - return NextResponse.redirect(new URL(servePath, request.url), { status: 302 }) + // Emit a relative Location so the browser resolves it against the public origin it requested. + // `NextResponse.redirect(new URL(servePath, request.url))` bakes in the host from `request.url`, + // which behind the load balancer is the internal pod host (e.g. ip-10-0-x.ec2.internal:3000) — + // unreachable from the browser, so embedded loads fail. A + // relative Location resolves against the original public URL (matching how the Files tab fetches + // serve URLs directly). + return new NextResponse(null, { status: 302, headers: { Location: servePath } }) } ) diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index a84cea989d4..ffc12f6721e 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -8,6 +8,7 @@ import { import { isE2BDocEnabled } from '@/lib/core/config/env-flags' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getE2BDocFormat } from './doc-compile' +import { buildEmbeddedImageRefWarning } from './embedded-image-refs' import { consumeLatestFileIntent } from './file-intent-store' import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file' @@ -250,9 +251,13 @@ export const editContentServerTool: BaseServerTool` embeds the model just authored that won't render/export + // (non-workspace or missing), so it can self-correct on the next step. + const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId) + return { success: true, - message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)`, + message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)${embedWarning}`, data: { id: intent.fileId, name: fileRecord.name, diff --git a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts new file mode 100644 index 00000000000..fb7a0eb5967 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts @@ -0,0 +1,51 @@ +import { getFileMetadataById } from '@/lib/uploads/server/metadata' + +/** The canonical embed form the file agent writes for workspace images: `/api/files/view/`. */ +const VIEW_EMBED_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g + +/** + * Returns the ids of `/api/files/view/` image embeds in `content` that will not render or survive a + * workspace export. An embed is valid only when its id resolves to a workspace file in this same + * workspace — the only thing the view route serves and an export can bundle. Every other case (missing, + * archived, a different workspace, or a non-`workspace` upload such as a chat-scoped `mothership` file) + * is flagged by id alone, without disclosing the referenced file's real context or owning workspace, so + * the result can't be used to probe files outside this workspace. Best-effort and never throws, so a + * content write is never blocked by this validation. + */ +export async function findUnembeddableImageRefs( + content: string, + workspaceId: string +): Promise { + const ids = new Set() + for (const match of content.matchAll(VIEW_EMBED_RE)) ids.add(match[1]) + if (ids.size === 0) return [] + + const checked = await Promise.all( + [...ids].map(async (id): Promise => { + try { + const record = await getFileMetadataById(id) + const embeddable = record?.context === 'workspace' && record.workspaceId === workspaceId + return embeddable ? null : id + } catch { + return null + } + }) + ) + + return checked.filter((id): id is string => id !== null) +} + +/** + * Builds an actionable suffix appended to a successful file-write tool result so the model can + * self-correct: only workspace files in this workspace embed, so any other reference must be re-saved + * into the workspace and re-referenced by the workspace file's id. Empty when there is nothing to flag. + */ +export async function buildEmbeddedImageRefWarning( + content: string, + workspaceId: string +): Promise { + const ids = await findUnembeddableImageRefs(content, workspaceId) + if (ids.length === 0) return '' + const list = ids.map((id) => `/api/files/view/${id}`).join('; ') + return ` Warning: embedded image(s) will not render or export because they are not workspace files in this workspace — ${list}. Save each image as a workspace file (under files/) and reference it via /api/files/view/.` +} diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index a490441d4cc..7ed7fb69dd2 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -34,6 +34,7 @@ import { getE2BDocFormat, PPTXGENJS_SOURCE_MIME, } from './doc-compile' +import { buildEmbeddedImageRefWarning } from './embedded-image-refs' import { storeFileIntent } from './file-intent-store' const logger = createLogger('WorkspaceFileServerTool') @@ -398,9 +399,11 @@ export const workspaceFileServerTool: BaseServerTool