Skip to content

Commit fdaaa05

Browse files
committed
fix(files): serve rendered documents instead of generator source
Generated docs (docx/pptx/pdf/xlsx) store their generation source as the primary file; the rendered binary lives in a separate content-addressed artifact store. resolveServableDocBytes is the chokepoint that swaps one for the other, and the serve route, single-file download and ~50 tool routes all go through it. Archive compression and the public v1 download read raw bytes instead, so a generated document arrived as source text under a .docx name and Word reported it as corrupt. Both now resolve through the servable reader. Adds fetchServableWorkspaceFileBuffer beside the raw reader so the record to UserFile mapping lives in one place, preserving storageContext; the raw reader's doc comment now says it returns generation source, so the next call site has to choose deliberately. v1 also has to send the resolved content type rather than the record's source MIME, and returns a retryable 409 rather than a 500 when an artifact is still compiling. docNotReadyMessage centralizes the 409 copy, and isRenderableDocumentName is shared so the read path and its callers agree on which extensions can expand.
1 parent 75b8b6f commit fdaaa05

6 files changed

Lines changed: 99 additions & 15 deletions

File tree

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
1111
import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file'
1212
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
1313
import { generateRequestId } from '@/lib/core/utils/request'
14+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1415
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
@@ -685,7 +686,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
685686
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
686687
if (denied) return denied
687688

688-
const buffer = await downloadFileFromStorage(userFile, requestId, logger, {
689+
// Generated docs store their generation source, not the rendered binary, so
690+
// the archive must carry the servable bytes instead of the raw source text.
691+
// A still-compiling artifact throws, and the handler's catch turns that into
692+
// the shared 409 via `docNotReadyResponse`.
693+
const { buffer } = await downloadServableFileFromStorage(userFile, requestId, logger, {
689694
maxBytes: MAX_COMPRESS_FILE_BYTES,
690695
})
691696
totalBytes += buffer.length
@@ -864,6 +869,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
864869
}
865870
const notReady = docNotReadyResponse(error)
866871
if (notReady) return notReady
872+
// A file over its per-file cap is a size rejection, not a fault. Rendered
873+
// documents can cross it even when the stored source was well under.
874+
if (isPayloadSizeLimitError(error)) {
875+
return NextResponse.json({ success: false, error: error.message }, { status: 413 })
876+
}
867877
if (error instanceof ShareValidationError) {
868878
return NextResponse.json({ success: false, error: error.message }, { status: 400 })
869879
}

apps/sim/app/api/v1/files/[fileId]/route.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { parseRequest } from '@/lib/api/server'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { captureServerEvent } from '@/lib/posthog/server'
9-
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
9+
import {
10+
fetchServableWorkspaceFileBuffer,
11+
getWorkspaceFile,
12+
} from '@/lib/uploads/contexts/workspace'
13+
import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response'
1014
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
1115
import {
1216
checkRateLimit,
@@ -48,7 +52,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
4852
return NextResponse.json({ error: 'File not found' }, { status: 404 })
4953
}
5054

51-
const buffer = await fetchWorkspaceFileBuffer(fileRecord)
55+
// Generated docs store their generation source; serve the rendered artifact.
56+
// Its content type is the rendered one, not the source MIME on the record.
57+
const { buffer, contentType } = await fetchServableWorkspaceFileBuffer(fileRecord)
5258

5359
recordAudit({
5460
workspaceId,
@@ -76,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
7682
return new Response(new Uint8Array(buffer), {
7783
status: 200,
7884
headers: {
79-
'Content-Type': fileRecord.type || 'application/octet-stream',
85+
'Content-Type': contentType || fileRecord.type || 'application/octet-stream',
8086
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
8187
'Content-Length': String(buffer.length),
8288
'X-File-Id': fileRecord.id,
@@ -88,6 +94,11 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
8894
},
8995
})
9096
} catch (error) {
97+
// A generated doc whose artifact is still compiling is retryable, not a fault:
98+
// without this the caller sees a 500 and has no reason to try again.
99+
if (isDocNotReadyError(error)) {
100+
return NextResponse.json({ error: docNotReadyMessage() }, { status: 409 })
101+
}
91102
logger.error(`[${requestId}] Error downloading file:`, error)
92103
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
93104
}

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
2121
import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
2222
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
2323
import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases'
24+
import { generateRequestId } from '@/lib/core/utils/request'
2425
import { generateRestoreName } from '@/lib/core/utils/restore-name'
2526
import type { DbOrTx } from '@/lib/db/types'
2627
import { getServePathPrefix } from '@/lib/uploads'
@@ -968,7 +969,42 @@ export async function getWorkspaceFile(
968969
}
969970

970971
/**
971-
* Download workspace file content
972+
* Download the bytes a user should actually receive for a workspace file.
973+
*
974+
* Generated docs (docx/pptx/pdf/xlsx) store their GENERATION SOURCE as the primary
975+
* file, so {@link fetchWorkspaceFileBuffer} hands back JavaScript/Python text under
976+
* a `.docx` name. This resolves the rendered artifact instead, and is what every
977+
* download/attachment surface should call. Reach for the raw reader only when the
978+
* source itself is wanted (style extraction, compile checks, the copilot VFS).
979+
*
980+
* Throws `DocCompileUserError` when a generated doc's artifact is still compiling —
981+
* callers surface a retryable 409 via `docNotReadyResponse` rather than shipping source.
982+
*/
983+
export async function fetchServableWorkspaceFileBuffer(
984+
fileRecord: WorkspaceFileRecord,
985+
options: { maxBytes?: number; signal?: AbortSignal } = {}
986+
): Promise<{ buffer: Buffer; contentType: string }> {
987+
const { downloadServableFileFromStorage } = await import('@/lib/uploads/utils/file-utils.server')
988+
989+
return downloadServableFileFromStorage(
990+
{
991+
id: fileRecord.id,
992+
name: fileRecord.name,
993+
url: fileRecord.url ?? fileRecord.path,
994+
size: fileRecord.size,
995+
type: fileRecord.type,
996+
key: fileRecord.key,
997+
context: fileRecord.storageContext ?? 'workspace',
998+
},
999+
generateRequestId(),
1000+
logger,
1001+
options
1002+
)
1003+
}
1004+
1005+
/**
1006+
* Download raw workspace file content. For generated docs this is the GENERATION
1007+
* SOURCE, not the rendered document — see {@link fetchServableWorkspaceFileBuffer}.
9721008
*/
9731009
export async function fetchWorkspaceFileBuffer(
9741010
fileRecord: WorkspaceFileRecord,

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getMimeTypeFromExtension,
2121
inferContextFromKey,
2222
isInternalFileUrl,
23+
isRenderableDocumentName,
2324
processSingleFileToUserFile,
2425
type RawFileInput,
2526
resolveTrustedFileContext,
@@ -375,8 +376,8 @@ export async function downloadServableFileFromStorage(
375376

376377
// Cheap pre-filter so only generated-doc candidates pay for the heavier resolver
377378
// import below.
378-
const ext = getFileExtension(userFile.name)
379-
if (ext !== 'pdf' && ext !== 'docx' && ext !== 'pptx' && ext !== 'xlsx') {
379+
if (!isRenderableDocumentName(userFile.name)) {
380+
const ext = getFileExtension(userFile.name)
380381
return { buffer, contentType: userFile.type || getMimeTypeFromExtension(ext) }
381382
}
382383

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,18 @@ export function getFileExtension(filename: string): string {
210210
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
211211
}
212212

213+
/**
214+
* Extensions whose stored bytes may be a generation source that renders to a larger
215+
* binary. Everything else stores exactly what it serves, so its declared size is
216+
* an accurate byte budget.
217+
*/
218+
const RENDERABLE_DOCUMENT_EXTENSIONS = new Set(['pdf', 'docx', 'pptx', 'xlsx'])
219+
220+
/** True when `fileName` may be backed by a generation source rather than final bytes. */
221+
export function isRenderableDocumentName(fileName: string): boolean {
222+
return RENDERABLE_DOCUMENT_EXTENSIONS.has(getFileExtension(fileName))
223+
}
224+
213225
const ARCHIVE_EXTENSIONS = new Set<string>(SUPPORTED_ARCHIVE_EXTENSIONS)
214226

215227
/**
Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
11
import { NextResponse } from 'next/server'
22
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile'
33

4+
/** True when `error` means a generated document's artifact is still compiling. */
5+
export function isDocNotReadyError(error: unknown): error is DocCompileUserError {
6+
return error instanceof DocCompileUserError
7+
}
8+
9+
/**
10+
* Message for a still-compiling generated document. Batch callers pass the names
11+
* they resolved so the copy says which documents to wait on.
12+
*/
13+
export function docNotReadyMessage(fileNames?: string[]): string {
14+
if (!fileNames || fileNames.length === 0) {
15+
return 'A document is still being generated. Wait for it to finish, then try again.'
16+
}
17+
const subject = fileNames.length === 1 ? 'A document is' : `${fileNames.length} documents are`
18+
return `${subject} still being generated: ${fileNames.join(', ')}. Wait for them to finish, then try again.`
19+
}
20+
421
/**
522
* Canonical retryable response for an attachment/upload whose generated-document
623
* artifact is still compiling. Returns the 409 when `error` is a
724
* {@link DocCompileUserError} (thrown by `downloadServableFileFromStorage`),
825
* otherwise `null` so the caller falls through to its own error handling. Shared
926
* by every tool route that downloads workspace files so the status, body shape,
1027
* and user-facing copy stay identical instead of being re-typed per route.
28+
*
29+
* Routes whose error envelope differs, or that resolved a batch and want the pending
30+
* files named, build the 409 themselves from {@link docNotReadyMessage}.
1131
*/
1232
export function docNotReadyResponse(error: unknown): NextResponse | null {
13-
if (error instanceof DocCompileUserError) {
14-
return NextResponse.json(
15-
{
16-
success: false,
17-
error: 'A document is still being generated. Wait for it to finish, then try again.',
18-
},
19-
{ status: 409 }
20-
)
33+
if (isDocNotReadyError(error)) {
34+
return NextResponse.json({ success: false, error: docNotReadyMessage() }, { status: 409 })
2135
}
2236
return null
2337
}

0 commit comments

Comments
 (0)