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
42 changes: 39 additions & 3 deletions apps/sim/app/api/files/serve/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,17 +571,53 @@ describe('File Serve API Route', () => {
describe('outside the workspace-file model', () => {
const key = 'workspace/ws/1790000000000-0123456789abcdef-clip.mp4'

function serveAttachment(headers: Record<string, string> = {}) {
function serveAttachment(headers: Record<string, string> = {}, objectKey = key) {
mockIsUsingCloudStorage.mockReturnValue(true)
storageServiceMockFns.mockDownloadFileStream.mockImplementation(async () =>
Readable.from([Buffer.from('attachment')])
)
return GET(
new NextRequest(`http://localhost:3000/api/files/serve/${key}`, { headers }),
createRouteContext({ path: key.split('/') })
new NextRequest(`http://localhost:3000/api/files/serve/${objectKey}`, { headers }),
createRouteContext({ path: objectKey.split('/') })
)
}

describe('a document name', () => {
const documentKey = 'workspace/ws/1790000000000-0123456789abcdef-scan.pdf'

beforeEach(() => {
storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 10, version: '"v1"' })
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('rendered'))
})

it('streams when its file record says it is a finished upload', async () => {
uploadsMetadataMockFns.mockGetFileMetadataByKey.mockResolvedValue({
key: documentKey,
contentType: 'application/pdf',
})

const response = await serveAttachment({}, documentKey)

expect(response.status).toBe(200)
expect(storageServiceMockFns.mockDownloadFileStream).toHaveBeenCalled()
expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled()
})

it.each([
['its record says it is a generation source', { contentType: 'text/x-pdflibjs' }],
['it has no file record', null],
])('stays buffered when %s', async (_label, record) => {
uploadsMetadataMockFns.mockGetFileMetadataByKey.mockResolvedValue(
record && { key: documentKey, ...record }
)

await serveAttachment({}, documentKey)

expect(storageServiceMockFns.mockDownloadFileStream).not.toHaveBeenCalled()
expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalled()
})
})

it('streams a range of an attachment sized by storage, never buffering it', async () => {
storageServiceMockFns.mockHeadObject.mockResolvedValue({
size: 500_000_000,
Expand Down
45 changes: 29 additions & 16 deletions apps/sim/app/api/files/serve/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,22 @@ import {
workspaceFileCacheControl,
} from '@/lib/uploads/server/delivery'
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
import { resolveStoredFileContext } from '@/lib/uploads/server/metadata'
import { getFileMetadataByKey, resolveStoredFileContext } from '@/lib/uploads/server/metadata'
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
import {
inferContextFromKey,
needsRenderedArtifact,
isRenderableDocumentName,
resolveEffectiveMimeType,
} from '@/lib/uploads/utils/file'
import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api'
import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer'
import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery'
import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-record-by-key'
import { streamAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/stream-authorized-workspace-file'
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server'
import { servesStoredBytes } from '@/lib/workspace-files/stored-bytes'
import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization'
import {
createConditionalFileResponse,
Expand Down Expand Up @@ -203,10 +205,10 @@ async function resolveTransformedBytes(params: {
* Whether a request is answered with the stored object byte-for-byte, so its bytes can stream from
* storage instead of being read whole and capped at the transfer ceiling.
*
* Every other answer is produced from the bytes and stays buffered: a Sim page renders, a generated
* document compiles, and a `preview` may be a HEIC transcode — sniffed from the content, so no name
* or type rules it out. What streams is exactly what {@link resolveServableBytes} and the
* workspace resolver would have passed through unchanged.
* Every other answer is produced from the bytes and stays buffered: see {@link servesStoredBytes},
* plus a `preview`, which may be a HEIC transcode — sniffed from the content, so no name or type
* rules it out. What streams is exactly what {@link resolveServableBytes} and the workspace
* resolver would have passed through unchanged.
*/
function servesStoredObject(
filename: string,
Expand All @@ -215,12 +217,7 @@ function servesStoredObject(
): boolean {
if (options.raw) return true
if (options.preview) return false
if (recordedType === SIM_PAGE_CONTENT_TYPE || filename.toLowerCase().endsWith('.html')) {
return false
}
// A recorded type tells a finished upload from a generation source; without one, every
// document name may be a source.
return !needsRenderedArtifact(recordedType, filename)
return servesStoredBytes(recordedType, filename)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}

/**
Expand All @@ -231,11 +228,26 @@ function storedObjectContentType(filename: string, options: ServeOptions): strin
return options.raw ? getContentType(filename) : resolveEffectiveMimeType(undefined, filename)
}

/**
* The type recorded for an object outside the workspace-file model, read only where it decides the
* answer: a document name may be a finished upload or a generation source, and the object's file
* record, where it has one, says which. An object with no record is judged by its name.
*/
async function recordedDocumentType(
key: string,
context: StorageContext,
filename: string,
options: ServeOptions
): Promise<string | undefined> {
if (options.raw || options.preview || !isRenderableDocumentName(filename)) return undefined
return (await getFileMetadataByKey(key, context))?.contentType
}

/**
* Streams a stored object outside the workspace-file model (execution outputs, chat and copilot
* attachments, knowledge documents) when the request is answered with it as-is, or returns null
* for the buffered path. The object is its only record, so storage is asked for its length and
* version, and the version is what proves a revalidation still holds.
* for the buffered path. Storage is asked for the object's length and version, and the version is
* what proves a revalidation still holds.
*/
async function streamStoredObject(params: {
key: string
Expand All @@ -245,7 +257,8 @@ async function streamStoredObject(params: {
request: NextRequest
}): Promise<NextResponse | null> {
const { key, context, filename, options } = params
if (!servesStoredObject(filename, undefined, options)) return null
const recordedType = await recordedDocumentType(key, context, filename, options)
if (!servesStoredObject(filename, recordedType, options)) return null
const stored = await headObject(key, context)
if (!stored) throw new FileNotFoundError(`File not found: ${key}`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file'
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { EditorContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu'
import { FileSaveConflict } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-save-conflict'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import {
isFileBrowserSize,
isFileBrowserType,
} from '@/lib/workspace-files/browser'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar'
import { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@/lib/mothership/resources/types'
import type { FileDownloadSource } from '@/lib/uploads/client/download'
import { getFileExtension } from '@/lib/uploads/utils/file'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
isCsvStreamOnly,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/mothership/tools/server/files/edit-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import {
HAND_WRITTEN_PAGE_MESSAGE,
isHandWrittenCompiledPage,
isSimPageSource,
SIM_PAGE_CONTENT_TYPE,
} from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { buildEmbeddedImageRefWarning } from './embedded-image-refs'
import { waitForLatestFileIntent } from './intent-store'
import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file'
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/lib/projects/files/application/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,8 @@ import {
textExtractionUnsupportedError,
} from '@/lib/workspace-files/application/read-workspace-file-text'
import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query'
import {
isSimPageSource,
ProjectPageForeignEmbedError,
SIM_PAGE_CONTENT_TYPE,
} from '@/lib/workspace-files/page-compile'
import { isSimPageSource, ProjectPageForeignEmbedError } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
import {
collectSimPageFileReferences,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/projects/files/application/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision'
import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write'
import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed'

interface ContentUseCaseArgs<I, P = undefined> {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/projects/files/application/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {
} from '@/lib/workspace-files/download-selection'
import { MAX_ZIP_DOWNLOAD_BYTES, MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits'
import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import {
createFileReadReceipt,
type FileReadReceipt,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/projects/files/application/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
type UploadSessionRecord,
} from '@/lib/uploads/upload-session/service'
import type { WorkspaceFileUploadSource } from '@/lib/uploads/upload-session/workspace-file-provenance'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import {
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
restoreSimPageSourceBuffer,
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/lib/public-shares/application/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,8 @@ import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file'
import { sniffImageContentType } from '@/lib/uploads/utils/validation'
import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer'
import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query'
import {
isSimPageSource,
ProjectPageForeignEmbedError,
SIM_PAGE_CONTENT_TYPE,
} from '@/lib/workspace-files/page-compile'
import { isSimPageSource, ProjectPageForeignEmbedError } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { renderSimPageDocument } from '@/lib/workspace-files/page-document'
import {
collectSimPageFileReferences,
Expand Down
21 changes: 14 additions & 7 deletions apps/sim/lib/uploads/client/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,18 @@ describe('large file downloads', () => {
expect(createObjectURL).toHaveBeenCalledOnce()
})

it('keeps fetching a large generated document, which must compile before it can be served', async () => {
await triggerFileDownload({ ...video, name: 'report.pdf', type: 'text/x-pdflibjs' })

expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0]?.[1]).not.toHaveProperty('headers')
expect(createObjectURL).toHaveBeenCalledOnce()
})
it.each([
['a generated document', { name: 'report.pdf', type: 'text/x-pdflibjs' }],
['a page recorded as one', { name: 'guide', type: 'text/x-sim-page' }],
['an .html page', { name: 'guide.html', type: 'text/html' }],
])(
'keeps fetching %s, which the server renders before it can be served',
async (_label, kind) => {
await triggerFileDownload({ ...video, ...kind })

expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0]?.[1]).not.toHaveProperty('headers')
expect(createObjectURL).toHaveBeenCalledOnce()
}
)
})
11 changes: 6 additions & 5 deletions apps/sim/lib/uploads/client/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { requestRaw } from '@/lib/api/client/request'
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { isMarkdownFile, needsRenderedArtifact } from '@/lib/uploads/utils/file'
import { isMarkdownFile } from '@/lib/uploads/utils/file'
import { normalizeMimeType } from '@/lib/uploads/utils/mime'
import { servesStoredBytes } from '@/lib/workspace-files/stored-bytes'

/** Action-time content from the mounted viewer, scoped so another file cannot consume it. */
export interface FileDownloadSource {
Expand All @@ -16,9 +17,9 @@ export interface FileDownloadSource {
/**
* Above this a stored file is handed to the browser's download manager instead of being read into
* the page. Holding it as a `Blob` costs its whole size in tab memory and shows no progress until
* the last byte lands; the download manager streams it to disk. Below it, and for a generated
* document, which must compile first, fetching keeps a rejection on screen as an error rather than
* as a failed entry in the browser's downloads list.
* the last byte lands; the download manager streams it to disk. Below it, and for a file the
* server renders first (a page, a generated document), fetching keeps a rejection on screen
* rather than as a failed entry in the browser's downloads list.
*/
const IN_PAGE_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024

Expand Down Expand Up @@ -99,7 +100,7 @@ export async function triggerFileDownload(

if (
!exportsMarkdown &&
!needsRenderedArtifact(record.type, record.name) &&
servesStoredBytes(record.type, record.name) &&
record.size > IN_PAGE_DOWNLOAD_MAX_BYTES
) {
// A first-byte read opens storage itself, so a missing object is refused here, in place,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ vi.mock('@/lib/uploads/contexts/workspace/file-folder-manager', () => workspaceF

import { createWorkspaceFileBodySchema } from '@/lib/api/contracts/workspace-files'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/file-manager'
import { compileSimPage, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { compileSimPage } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'

workspaceFileFoldersMockFns.mockBuildWorkspaceFileFolderPathMap.mockImplementation(() => new Map())
workspaceFileFoldersMockFns.mockNormalizeWorkspaceFileItemName.mockImplementation(
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/uploads/contexts/workspace/file-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ import {
resolveFileOwner,
} from '@/lib/workspace-files/ownership'
import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import {
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
restoreSimPageSourceBuffer,
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/uploads/utils/file.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ import {
type RawFileInput,
resolveTrustedFileContext,
} from '@/lib/uploads/utils/file'
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { renderSimPageDocumentWithContributors } from '@/lib/workspace-files/page-document.server'
import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization'
import type { UserFile } from '@/executor/types'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import { createFileReadReceipt } from '@/lib/workspace-files/read-receipt'
* Opens an already-authorized workspace file's stored bytes for delivery, whole or as one inclusive
* span, without reading them into memory.
*
* Callers must already have authorized and admitted the file (`'opaque'`) through their own use
* case: storage is opened before this function's own recheck, which only decides whether the
* opened stream is handed out.
*
* Access is rechecked once storage has opened and before the stream is handed out, as every
* streamed read does: a revocation, a content write, or a provenance change landing in between
* disposes the stream instead of publishing it. `authorize` is the caller's own use-case check, so
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/workspace-files/archive-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file'
import type { EditableFileOwner } from '@/lib/workspace-files/ownership'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-content-type'
import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed'

interface StagedArchiveEntry {
Expand Down
9 changes: 0 additions & 9 deletions apps/sim/lib/workspace-files/page-compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,6 @@ import type { EditableFileOwner } from '@/lib/workspace-files/ownership'
*/
export const SIM_PAGE_MARKER = '<!--sim-page-->'

/**
* The INTERNAL content type stamped onto a page file's record when the write
* path detects page source. The file stays `.html` to the user (serving and
* downloads emit text/html); the record type is how surfaces know the file's
* nature before any content loads — force the rendered view, hide the code
* toggle, expect source.
*/
export const SIM_PAGE_CONTENT_TYPE = 'text/x-sim-page'

const frontmatterSchema = z.object({
title: z.string().min(1),
/** Tolerated for old sources; no longer rendered — the docs have no eyebrow. */
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/lib/workspace-files/page-content-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* The INTERNAL content type stamped onto a page file's record when the write
* path detects page source. The file stays `.html` to the user (serving and
* downloads emit text/html); the record type is how surfaces know the file's
* nature before any content loads — force the rendered view, hide the code
* toggle, expect source.
*
* Kept free of the page compiler so client surfaces that only need to recognize a page
* do not load the compiler's parsers.
*/
export const SIM_PAGE_CONTENT_TYPE = 'text/x-sim-page'
Loading
Loading