Skip to content

Commit 428b57c

Browse files
committed
fix(files): fence content delivery and preserve shared render caches
1 parent deecb7a commit 428b57c

25 files changed

Lines changed: 613 additions & 282 deletions

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,14 @@ export const GET = withRouteHandler(
430430
return createFileErrorResponse(notFound)
431431
}
432432

433-
if (orchestrationError)
433+
if (orchestrationError) {
434+
if (orchestrationError.code === 'internal')
435+
logServeFailure('Error serving file:', orchestrationError)
434436
return createFileErrorResponse(
435437
orchestrationError,
436438
statusForOrchestrationError(orchestrationError.code)
437439
)
440+
}
438441

439442
logServeFailure('Error serving file:', error)
440443

‎apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts‎

Lines changed: 230 additions & 95 deletions
Large diffs are not rendered by default.

‎apps/sim/lib/projects/files/application/artifacts.ts‎

Lines changed: 130 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { db } from '@sim/db'
21
import type { WorkspaceFileRow } from '@sim/db/schema'
32
import { workspaceFiles } from '@sim/db/schema'
4-
import { createLogger } from '@sim/logger'
5-
import { describeError } from '@sim/utils/errors'
63
import { and, asc, inArray, isNull } from 'drizzle-orm'
4+
import { requireAdmittedContent } from '@/lib/core/application/content-delivery'
75
import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
86
import { OrchestrationError } from '@/lib/core/orchestration/types'
97
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
108
import type { DbTransaction } from '@/lib/db/types'
11-
import type { ProjectFileTarget } from '@/lib/projects/files/application/authorization'
9+
import type {
10+
ProjectFileAuthorizationContext,
11+
ProjectFileTarget,
12+
} from '@/lib/projects/files/application/authorization'
1213
import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case'
1314
import { projectFileOperations } from '@/lib/projects/files/application/operations'
1415
import {
@@ -23,11 +24,7 @@ import {
2324
mergeWorkspaceFileSecretProvenance,
2425
type WorkspaceFileSecretProvenance,
2526
} from '@/lib/uploads/contexts/workspace/file-secret-provenance'
26-
import {
27-
enqueueWorkspaceFileStorageCleanups,
28-
processWorkspaceFileStorageCleanupsNow,
29-
} from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox'
30-
import { deleteFile, downloadFile } from '@/lib/uploads/core/storage-service'
27+
import { downloadFile } from '@/lib/uploads/core/storage-service'
3128
import {
3229
collectReferencedFileIds,
3330
compileFileDocument,
@@ -50,8 +47,6 @@ import {
5047
import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt'
5148
import { markFileSearchArtifactReadyInTx } from '@/lib/workspace-files/search/artifact-ready'
5249

53-
const logger = createLogger('ProjectFileArtifacts')
54-
5550
type ProjectOwner = { entityType: 'project'; entityId: string }
5651
interface ArtifactInput extends ProjectFileTarget {
5752
fileId: string
@@ -68,10 +63,10 @@ interface ArtifactManifest {
6863
dependencies: WorkspaceFileRow[]
6964
pageHtml?: string
7065
generatedDocument: boolean
66+
secretProvenance: WorkspaceFileSecretProvenance
7167
}
7268
interface PreparedArtifact {
7369
artifactKey?: string
74-
writtenArtifactKeys: readonly string[]
7570
manifest: ArtifactManifest
7671
buffer: Buffer
7772
contentType: string
@@ -86,45 +81,6 @@ interface ArtifactResult {
8681
receipt: FileReadReceipt
8782
}
8883

89-
async function discardArtifactWrites(owner: ProjectOwner, keys: readonly string[]) {
90-
if (keys.length === 0) return
91-
const derivativePrefix = `project/${owner.entityId}/image-derivative/`
92-
if (
93-
keys.some(
94-
(key) =>
95-
!key.startsWith(`project/${owner.entityId}/compiled/`) &&
96-
!(
97-
key.startsWith(derivativePrefix) &&
98-
/^[a-f0-9]{64}\.jpg$/.test(key.slice(derivativePrefix.length))
99-
)
100-
)
101-
)
102-
throw new Error('Artifact cleanup does not belong to this Project')
103-
let events: string[]
104-
try {
105-
events = await enqueueWorkspaceFileStorageCleanups(db, keys, 'project')
106-
} catch (error) {
107-
logger.warn('Artifact cleanup could not be persisted; attempting direct deletion', {
108-
owner,
109-
keys,
110-
error: describeError(error),
111-
})
112-
for (const key of keys) {
113-
try {
114-
await deleteFile({ key, context: 'project' })
115-
} catch (cleanupError) {
116-
logger.error('Uncommitted artifact could not be deleted or queued for cleanup', {
117-
owner,
118-
key,
119-
error: describeError(cleanupError),
120-
})
121-
}
122-
}
123-
return
124-
}
125-
await processWorkspaceFileStorageCleanupsNow(events, { owner, reason: 'artifact read failed' })
126-
}
127-
12884
function requireSameRevision(current: WorkspaceFileRow | undefined, expected: WorkspaceFileRow) {
12985
if (
13086
!current ||
@@ -154,6 +110,34 @@ async function loadDependencies(tx: DbTransaction, owner: ProjectOwner, ids: str
154110
return rows
155111
}
156112

113+
const snapshotSource = defineAuthorizedProjectFileUseCase<
114+
typeof projectFileOperations.readArtifact,
115+
ArtifactInput,
116+
{ file: WorkspaceFileRow; secretProvenance: WorkspaceFileSecretProvenance | undefined }
117+
>({
118+
operation: projectFileOperations.readArtifact,
119+
async execute({
120+
context,
121+
tx,
122+
}: {
123+
context: ProjectFileAuthorizationContext
124+
tx: DbTransaction
125+
input: ArtifactInput
126+
}) {
127+
const file = context.file
128+
if (!file) throw new OrchestrationError('not_found', 'File not found')
129+
const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])
130+
return { file, secretProvenance: evidence.get(file.id) }
131+
},
132+
afterSuccess: ({ principal, result }) =>
133+
requireAdmittedContent(principal, {
134+
kind: 'workspace-file',
135+
id: result.file.id,
136+
provenance: result.secretProvenance ?? { status: 'unknown' },
137+
view: 'opaque',
138+
}),
139+
})
140+
157141
const snapshotArtifact = defineAuthorizedProjectFileUseCase<
158142
typeof projectFileOperations.readArtifact,
159143
ArtifactInput & { source: SourceSnapshot },
@@ -203,8 +187,25 @@ const snapshotArtifact = defineAuthorizedProjectFileUseCase<
203187
'Document exceeds the referenced input limit'
204188
)
205189
const dependencies = await loadDependencies(tx, context.owner, ids)
206-
return { source: input.source.file, dependencies, pageHtml, generatedDocument }
190+
const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [
191+
input.source.file,
192+
...dependencies,
193+
])
194+
return {
195+
source: input.source.file,
196+
dependencies,
197+
pageHtml,
198+
generatedDocument,
199+
secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence.values()),
200+
}
207201
},
202+
afterSuccess: ({ principal, result }) =>
203+
requireAdmittedContent(principal, {
204+
kind: 'workspace-file',
205+
id: result.source.id,
206+
provenance: result.secretProvenance,
207+
view: 'opaque',
208+
}),
208209
})
209210

210211
/** Renders only source-owned dependencies, then rechecks the source, every input, and current authority. */
@@ -223,97 +224,84 @@ export const readProjectFileArtifact = defineAuthorizedProjectFileUseCase<
223224
) {
224225
throw new OrchestrationError('validation', 'Invalid document byte limit')
225226
}
226-
const file = context.file
227-
if (!file) throw new OrchestrationError('not_found', 'File not found')
228-
const writtenArtifactKeys = new Set<string>()
229-
try {
230-
const artifact = await resolveDocumentRender(
231-
file.originalName,
232-
{ maxBytes: input.maxBytes },
233-
async () => {
234-
const content = await downloadFile({
235-
key: file.key,
227+
const { file } = await snapshotSource.execute({ principal, input })
228+
const artifact = await resolveDocumentRender(
229+
file.originalName,
230+
{ maxBytes: input.maxBytes },
231+
async () => {
232+
const content = await downloadFile({
233+
key: file.key,
234+
context: 'project',
235+
maxBytes: input.maxBytes,
236+
})
237+
const manifest = await snapshotArtifact.execute({
238+
principal,
239+
input: { ...input, source: { file, content } },
240+
})
241+
let remaining = manifest.pageHtml ? 32 * 1024 * 1024 : 50 * 1024 * 1024
242+
const inputs = []
243+
for (const dependency of manifest.dependencies) {
244+
const bytes = await downloadFile({
245+
key: dependency.key,
236246
context: 'project',
237-
maxBytes: input.maxBytes,
247+
maxBytes: Math.min(remaining, manifest.pageHtml ? 8 * 1024 * 1024 : 25 * 1024 * 1024),
238248
})
239-
const manifest = await snapshotArtifact.execute({
240-
principal,
241-
input: { ...input, source: { file, content } },
249+
remaining -= bytes.length
250+
inputs.push({
251+
fileId: dependency.id,
252+
contentType: dependency.contentType,
253+
content: bytes,
242254
})
243-
let remaining = manifest.pageHtml ? 32 * 1024 * 1024 : 50 * 1024 * 1024
244-
const inputs = []
245-
for (const dependency of manifest.dependencies) {
246-
const bytes = await downloadFile({
247-
key: dependency.key,
248-
context: 'project',
249-
maxBytes: Math.min(remaining, manifest.pageHtml ? 8 * 1024 * 1024 : 25 * 1024 * 1024),
250-
})
251-
remaining -= bytes.length
252-
inputs.push({
253-
fileId: dependency.id,
254-
contentType: dependency.contentType,
255-
content: bytes,
256-
})
257-
}
258-
if (manifest.pageHtml) {
259-
const byId = new Map(inputs.map((input) => [input.fileId, input]))
260-
const page = await inlineSimPageImages(
261-
manifest.pageHtml,
262-
async ({ fileId }) => {
263-
const asset = byId.get(fileId)
264-
if (!asset) throw new OrchestrationError('not_found', 'Document input not found')
265-
return { bytes: asset.content, contentType: asset.contentType, identity: fileId }
266-
},
267-
{ strict: true }
268-
)
269-
const buffer = Buffer.from(page.html, 'utf-8')
270-
assertKnownSizeWithinLimit(buffer.length, input.maxBytes, 'rendered page')
271-
return {
272-
manifest,
273-
buffer,
274-
contentType: 'text/html',
275-
dependsOnReferencedFiles: inputs.length > 0,
276-
}
277-
}
278-
if (manifest.generatedDocument) {
279-
const inputIdentity = fileDocumentInputIdentity(context.owner, manifest.dependencies)
280-
const compiled = await compileFileDocument({
281-
owner: context.owner,
282-
source: content.toString('utf-8'),
283-
fileName: file.originalName,
284-
inputs,
285-
onArtifactWrite: (key) => {
286-
writtenArtifactKeys.add(key)
287-
},
288-
inputIdentity,
289-
maxBytes: input.maxBytes,
290-
})
291-
return { manifest, ...compiled }
292-
}
255+
}
256+
if (manifest.pageHtml) {
257+
const byId = new Map(inputs.map((input) => [input.fileId, input]))
258+
const page = await inlineSimPageImages(
259+
manifest.pageHtml,
260+
async ({ fileId }) => {
261+
const asset = byId.get(fileId)
262+
if (!asset) throw new OrchestrationError('not_found', 'Document input not found')
263+
return { bytes: asset.content, contentType: asset.contentType, identity: fileId }
264+
},
265+
{ strict: true }
266+
)
267+
const buffer = Buffer.from(page.html, 'utf-8')
268+
assertKnownSizeWithinLimit(buffer.length, input.maxBytes, 'rendered page')
293269
return {
294270
manifest,
295-
buffer: content,
296-
contentType: file.contentType,
297-
dependsOnReferencedFiles: false,
271+
buffer,
272+
contentType: 'text/html',
273+
dependsOnReferencedFiles: inputs.length > 0,
298274
}
299275
}
300-
)
301-
const derivative = input.preview
302-
? await resolveServableImageBytes(artifact.buffer, artifact.manifest.source.key, {
276+
if (manifest.generatedDocument) {
277+
const inputIdentity = fileDocumentInputIdentity(context.owner, manifest.dependencies)
278+
const compiled = await compileFileDocument({
303279
owner: context.owner,
304-
onArtifactWrite: (key) => writtenArtifactKeys.add(key),
280+
source: content.toString('utf-8'),
281+
fileName: file.originalName,
282+
inputs,
283+
inputIdentity,
284+
maxBytes: input.maxBytes,
305285
})
306-
: null
307-
if (derivative)
308-
assertKnownSizeWithinLimit(derivative.buffer.length, input.maxBytes, 'image preview')
309-
return { ...artifact, ...derivative, writtenArtifactKeys: [...writtenArtifactKeys] }
310-
} catch (error) {
311-
await discardArtifactWrites(context.owner, [...writtenArtifactKeys])
312-
throw error
313-
}
286+
return { manifest, ...compiled }
287+
}
288+
return {
289+
manifest,
290+
buffer: content,
291+
contentType: file.contentType,
292+
dependsOnReferencedFiles: false,
293+
}
294+
}
295+
)
296+
const derivative = input.preview
297+
? await resolveServableImageBytes(artifact.buffer, artifact.manifest.source.key, {
298+
owner: context.owner,
299+
})
300+
: null
301+
if (derivative)
302+
assertKnownSizeWithinLimit(derivative.buffer.length, input.maxBytes, 'image preview')
303+
return { ...artifact, ...derivative }
314304
},
315-
onCommitFailure: ({ context, prepared }) =>
316-
discardArtifactWrites(context.owner, prepared.writtenArtifactKeys),
317305
async execute({ input, context, tx, prepared }) {
318306
if (!prepared) throw new Error('Prepared document is unavailable')
319307
requireSameRevision(context.file, prepared.manifest.source)
@@ -361,6 +349,13 @@ export const readProjectFileArtifact = defineAuthorizedProjectFileUseCase<
361349
receipt: createFileReadReceipt(context.owner, [source, ...dependencies]),
362350
}
363351
},
364-
afterSuccess: ({ result }) =>
365-
reportWorkspaceFileDelivery(result.secretProvenance, result.file.id),
352+
async afterSuccess({ principal, result }) {
353+
await requireAdmittedContent(principal, {
354+
kind: 'workspace-file',
355+
id: result.file.id,
356+
provenance: result.secretProvenance,
357+
view: 'opaque',
358+
})
359+
reportWorkspaceFileDelivery(result.secretProvenance, result.file.id)
360+
},
366361
})

0 commit comments

Comments
 (0)