Files
simstudioai--sim/apps/sim/app/api/files/serve/[...path]/route.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

346 lines
10 KiB
TypeScript

import { readFile } from 'fs/promises'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
DocCompileUserError,
resolveServableDocBytes,
} from '@/lib/copilot/tools/server/files/doc-compile'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { verifyFileAccess } from '@/app/api/files/authorization'
import {
createErrorResponse,
createFileResponse,
FileNotFoundError,
findLocalFile,
getContentType,
} from '@/app/api/files/utils'
const logger = createLogger('FilesServeAPI')
/**
* Resolves the bytes + content type to serve for a stored file via the shared
* {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
* bypasses resolution and serves the stored source as-is.
*/
async function compileDocumentIfNeeded(
buffer: Buffer,
filename: string,
workspaceId: string | undefined,
raw: boolean,
ownerKey: string | undefined,
signal: AbortSignal | undefined
): Promise<{ buffer: Buffer; contentType: string }> {
if (raw) return { buffer, contentType: getContentType(filename) }
return resolveServableDocBytes({
rawBuffer: buffer,
fileName: filename,
workspaceId,
ownerKey,
signal,
})
}
const STORAGE_KEY_PREFIX_RE = /^\d{13}-[a-z0-9]{7}-/
function stripStorageKeyPrefix(segment: string): string {
return STORAGE_KEY_PREFIX_RE.test(segment) ? segment.replace(STORAGE_KEY_PREFIX_RE, '') : segment
}
function getWorkspaceIdForCompile(key: string): string | undefined {
return parseWorkspaceFileKey(key) ?? undefined
}
const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
/**
* Cache-Control for a served file. A versioned request (`?v=<updatedAt>`) addresses
* content-immutable bytes — generated docs are content-addressed and the version
* bumps on every edit — so the browser may cache it indefinitely; re-opens and
* focus refetches then resolve from cache with no round trip. Unversioned workspace
* reads stay revalidated because the same storage key is edited in place.
*/
function resolveServeCacheControl(
versioned: boolean,
context: string | undefined
): string | undefined {
if (versioned) return IMMUTABLE_CACHE_CONTROL
return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined
}
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
try {
const paramsResult = fileServeParamsSchema.safeParse(await params)
if (!paramsResult.success) {
throw new FileNotFoundError('No file path provided')
}
const { path } = paramsResult.data
if (!path || path.length === 0) {
throw new FileNotFoundError('No file path provided')
}
logger.info('File serve request:', { path })
const fullPath = path.join('/')
const isS3Path = path[0] === 's3'
const isBlobPath = path[0] === 'blob'
const isCloudPath = isS3Path || isBlobPath
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const isPublicByKeyPrefix =
cloudKey.startsWith('profile-pictures/') ||
cloudKey.startsWith('og-images/') ||
cloudKey.startsWith('workspace-logos/')
if (isPublicByKeyPrefix) {
const context = inferContextFromKey(cloudKey)
logger.info(`Serving public ${context}:`, { cloudKey })
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxyPublic(cloudKey, context)
}
return await handleLocalFilePublic(fullPath)
}
const query = fileServeQuerySchema.parse({
raw: request.nextUrl.searchParams.get('raw'),
v: request.nextUrl.searchParams.get('v'),
})
const raw = query.raw === '1'
const versioned = query.v != null
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn('Unauthorized file access attempt', {
path,
error: authResult.error || 'Missing userId',
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = authResult.userId
if (isUsingCloudStorage()) {
return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal)
}
return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal)
} catch (error) {
// An in-progress/incomplete doc source fails to compile — this is expected
// mid-generation, not a server fault. Return 409 (not 500) so it isn't an
// alarming error; the client re-fetches once the doc finishes (the serve
// URL is busted on the file's updatedAt).
if (error instanceof DocCompileUserError) {
logger.info('Serve: document still compiling, returning 409', {
message: error.message,
})
return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 })
}
logger.error('Error serving file:', error)
if (error instanceof FileNotFoundError) {
return createErrorResponse(error)
}
return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file'))
}
}
)
async function handleLocalFile(
filename: string,
userId: string,
raw: boolean,
versioned: boolean,
signal: AbortSignal | undefined
): Promise<NextResponse> {
const ownerKey = `user:${userId}`
try {
const contextParam: StorageContext | undefined = inferContextFromKey(filename) as
| StorageContext
| undefined
const hasAccess = await verifyFileAccess(
filename,
userId,
undefined, // customConfig
contextParam, // context
true // isLocal
)
if (!hasAccess) {
logger.warn('Unauthorized local file access attempt', { userId, filename })
throw new FileNotFoundError(`File not found: ${filename}`)
}
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
}
const rawBuffer = await readFile(filePath)
const segment = filename.split('/').pop() || filename
const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(filename)
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
rawBuffer,
displayName,
workspaceId,
raw,
ownerKey,
signal
)
logger.info('Local file served', { userId, filename, size: fileBuffer.length })
return createFileResponse({
buffer: fileBuffer,
contentType,
filename: displayName,
cacheControl: resolveServeCacheControl(versioned, contextParam),
})
} catch (error) {
logger.error('Error reading local file:', error)
throw error
}
}
async function handleCloudProxy(
cloudKey: string,
userId: string,
raw = false,
versioned = false,
signal: AbortSignal | undefined = undefined
): Promise<NextResponse> {
const ownerKey = `user:${userId}`
try {
const context = inferContextFromKey(cloudKey)
logger.info(`Inferred context: ${context} from key pattern: ${cloudKey}`)
const hasAccess = await verifyFileAccess(
cloudKey,
userId,
undefined, // customConfig
context, // context
false // isLocal
)
if (!hasAccess) {
logger.warn('Unauthorized cloud file access attempt', { userId, key: cloudKey, context })
throw new FileNotFoundError(`File not found: ${cloudKey}`)
}
let rawBuffer: Buffer
if (context === 'copilot') {
rawBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
} else {
rawBuffer = await downloadFile({
key: cloudKey,
context,
})
}
const segment = cloudKey.split('/').pop() || 'download'
const displayName = stripStorageKeyPrefix(segment)
const workspaceId = getWorkspaceIdForCompile(cloudKey)
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
rawBuffer,
displayName,
workspaceId,
raw,
ownerKey,
signal
)
logger.info('Cloud file served', {
userId,
key: cloudKey,
size: fileBuffer.length,
context,
})
return createFileResponse({
buffer: fileBuffer,
contentType,
filename: displayName,
cacheControl: resolveServeCacheControl(versioned, context),
})
} catch (error) {
logger.error('Error downloading from cloud storage:', error)
throw error
}
}
async function handleCloudProxyPublic(
cloudKey: string,
context: StorageContext
): Promise<NextResponse> {
try {
let fileBuffer: Buffer
if (context === 'copilot') {
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
} else {
fileBuffer = await downloadFile({
key: cloudKey,
context,
})
}
const filename = cloudKey.split('/').pop() || 'download'
const contentType = getContentType(filename)
logger.info('Public cloud file served', {
key: cloudKey,
size: fileBuffer.length,
context,
})
return createFileResponse({
buffer: fileBuffer,
contentType,
filename,
})
} catch (error) {
logger.error('Error serving public cloud file:', error)
throw error
}
}
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
try {
const filePath = await findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
}
const fileBuffer = await readFile(filePath)
const contentType = getContentType(filename)
logger.info('Public local file served', { filename, size: fileBuffer.length })
return createFileResponse({
buffer: fileBuffer,
contentType,
filename,
})
} catch (error) {
logger.error('Error reading public local file:', error)
throw error
}
}