Skip to content

Commit 7b672ae

Browse files
committed
Downloaded pages carry their images
Absolute URLs made LINKS survive a download, but an embedded image request from a downloaded file is cross-site and carries no session cookie, so images 401ed outside the app. The standalone renderer now inlines every workspace image the page references as a data: URI at serve time — like a pdf carrying its images — capped at 8MB per image, restricted to the page's own workspace, falling back to the URL reference on any miss. Applies to serve, download, and public shares.
1 parent b29f182 commit 7b672ae

4 files changed

Lines changed: 56 additions & 6 deletions

File tree

‎apps/sim/app/api/files/public/[token]/content/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
1313
import { downloadFile } from '@/lib/uploads/core/storage-service'
1414
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
1515
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
16-
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
16+
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
1717
import {
1818
createErrorResponse,
1919
createFileResponse,
@@ -100,7 +100,7 @@ export const GET = withRouteHandler(
100100
// routes. sim: links resolve to workspace routes (a viewer without
101101
// workspace access simply lands on the sign-in gate).
102102
buffer = Buffer.from(
103-
renderSimPageDocument(raw.toString('utf8'), {
103+
await renderSimPageDocumentWithAssets(raw.toString('utf8'), {
104104
workspaceId: file.workspaceId ?? undefined,
105105
}),
106106
'utf8'

‎apps/sim/app/api/files/serve/[...path]/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
2323
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
2424
import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
2525
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
26-
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
26+
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
2727
import { verifyFileAccess } from '@/app/api/files/authorization'
2828
import {
2929
createErrorResponse,
@@ -93,7 +93,7 @@ async function resolveServableBytes(params: {
9393
const text = buffer.toString('utf8')
9494
if (isSimPageSource(text)) {
9595
return {
96-
buffer: Buffer.from(renderSimPageDocument(text, { workspaceId }), 'utf8'),
96+
buffer: Buffer.from(await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8'),
9797
contentType: 'text/html',
9898
}
9999
}

‎apps/sim/lib/uploads/utils/file-utils.server.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
resolveTrustedFileContext,
3535
} from '@/lib/uploads/utils/file-utils'
3636
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
37-
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
37+
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
3838
import { verifyFileAccess } from '@/app/api/files/authorization'
3939
import type { UserFile } from '@/executor/types'
4040

@@ -430,7 +430,7 @@ export async function downloadServableFileFromStorage(
430430
? (parseWorkspaceFileKey(userFile.key) ?? undefined)
431431
: undefined
432432
return {
433-
buffer: Buffer.from(renderSimPageDocument(text, { workspaceId }), 'utf8'),
433+
buffer: Buffer.from(await renderSimPageDocumentWithAssets(text, { workspaceId }), 'utf8'),
434434
contentType: 'text/html',
435435
}
436436
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { downloadFile } from '@/lib/uploads/core/storage-service'
2+
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
3+
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
4+
5+
/** Images past this size stay as URL references rather than bloating the document. */
6+
const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024
7+
8+
const IMAGE_SRC = /src="[^"]*\/api\/files\/view\/([^"]+)"/g
9+
10+
/**
11+
* The full pdf model for the standalone document: like a pdf carrying its
12+
* images, the served page inlines every workspace image it references as a
13+
* data: URI. Absolute link URLs already survive a download, but an embedded
14+
* image request from a downloaded file is cross-site and carries no session
15+
* cookie, so only baked-in bytes render everywhere. Images must live in the
16+
* page's own workspace — a reference into another workspace stays a URL and
17+
* renders only where the viewer's own session authorizes it.
18+
*/
19+
export async function renderSimPageDocumentWithAssets(
20+
source: string,
21+
options: { workspaceId?: string }
22+
): Promise<string> {
23+
const documentHtml = renderSimPageDocument(source, options)
24+
const ids = [...new Set([...documentHtml.matchAll(IMAGE_SRC)].map((match) => match[1]))]
25+
if (ids.length === 0 || !options.workspaceId) return documentHtml
26+
27+
const inlined = new Map<string, string>()
28+
await Promise.all(
29+
ids.map(async (id) => {
30+
try {
31+
const record = await getFileMetadataById(id)
32+
if (!record || record.context !== 'workspace' || record.workspaceId !== options.workspaceId)
33+
return
34+
const bytes = await downloadFile({ key: record.key, context: 'workspace' })
35+
if (bytes.length > MAX_INLINE_IMAGE_BYTES) return
36+
const mime = record.contentType?.startsWith('image/')
37+
? record.contentType
38+
: 'application/octet-stream'
39+
inlined.set(id, `data:${mime};base64,${bytes.toString('base64')}`)
40+
} catch {
41+
// A missing or unreadable image keeps its URL reference.
42+
}
43+
})
44+
)
45+
if (inlined.size === 0) return documentHtml
46+
return documentHtml.replace(IMAGE_SRC, (match, id: string) => {
47+
const dataUri = inlined.get(id)
48+
return dataUri ? `src="${dataUri}"` : match
49+
})
50+
}

0 commit comments

Comments
 (0)