Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions apps/sim/app/api/files/view/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 <img src="/api/files/view/<id>"> 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 } })
}
)
7 changes: 6 additions & 1 deletion apps/sim/lib/copilot/tools/server/files/edit-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -250,9 +251,13 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
userId: context.userId,
})

// Flag any `/api/files/view/<id>` 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,
Expand Down
51 changes: 51 additions & 0 deletions apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts
Original file line number Diff line number Diff line change
@@ -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/<fileId>`. */
const VIEW_EMBED_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g

/**
* Returns the ids of `/api/files/view/<id>` 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<string[]> {
const ids = new Set<string>()
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<string | null> => {
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<string> {
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/<workspace-file-id>.`
}
5 changes: 4 additions & 1 deletion apps/sim/lib/copilot/tools/server/files/workspace-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -398,9 +399,11 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
userId: context.userId,
})

const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId)

return {
success: true,
message: `File "${fileName}" created successfully (${fileBuffer.length} bytes)`,
message: `File "${fileName}" created successfully (${fileBuffer.length} bytes)${embedWarning}`,
data: {
id: result.id,
name: result.name,
Expand Down
Loading