chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Tests for file serve API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockVerifyFileAccess,
|
||||
mockReadFile,
|
||||
mockIsUsingCloudStorage,
|
||||
mockDownloadCopilotFile,
|
||||
mockInferContextFromKey,
|
||||
mockGetContentType,
|
||||
mockFindLocalFile,
|
||||
mockCreateFileResponse,
|
||||
mockCreateErrorResponse,
|
||||
FileNotFoundError,
|
||||
} = vi.hoisted(() => {
|
||||
class FileNotFoundErrorClass extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'FileNotFoundError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
mockVerifyFileAccess: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockIsUsingCloudStorage: vi.fn(),
|
||||
mockDownloadCopilotFile: vi.fn(),
|
||||
mockInferContextFromKey: vi.fn(),
|
||||
mockGetContentType: vi.fn(),
|
||||
mockFindLocalFile: vi.fn(),
|
||||
mockCreateFileResponse: vi.fn(),
|
||||
mockCreateErrorResponse: vi.fn(),
|
||||
FileNotFoundError: FileNotFoundErrorClass,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: mockReadFile,
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true, size: 100 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
verifyFileAccess: mockVerifyFileAccess,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads', () => ({
|
||||
CopilotFiles: {
|
||||
downloadCopilotFile: mockDownloadCopilotFile,
|
||||
},
|
||||
isUsingCloudStorage: mockIsUsingCloudStorage,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
inferContextFromKey: mockInferContextFromKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/setup.server', () => ({}))
|
||||
|
||||
vi.mock('@/lib/execution/sandbox/run-task', () => ({
|
||||
runSandboxTask: vi
|
||||
.fn()
|
||||
.mockImplementation(async (taskId: string) =>
|
||||
taskId === 'pdf-generate' ? Buffer.from('%PDF-compiled') : Buffer.from('PK\x03\x04compiled')
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
parseWorkspaceFileKey: vi.fn().mockReturnValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/files/utils', () => ({
|
||||
FileNotFoundError,
|
||||
createFileResponse: mockCreateFileResponse,
|
||||
createErrorResponse: mockCreateErrorResponse,
|
||||
getContentType: mockGetContentType,
|
||||
extractStorageKey: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
extractFilename: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
|
||||
findLocalFile: mockFindLocalFile,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/files/serve/[...path]/route'
|
||||
|
||||
describe('File Serve API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockVerifyFileAccess.mockResolvedValue(true)
|
||||
mockReadFile.mockResolvedValue(Buffer.from('test content'))
|
||||
mockIsUsingCloudStorage.mockReturnValue(false)
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mockInferContextFromKey.mockReturnValue('workspace')
|
||||
mockGetContentType.mockReturnValue('text/plain')
|
||||
mockFindLocalFile.mockReturnValue('/test/uploads/test-file.txt')
|
||||
mockCreateFileResponse.mockImplementation(
|
||||
(file: { buffer: Buffer; contentType: string; filename: string }) => {
|
||||
return new Response(file.buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType,
|
||||
'Content-Disposition': `inline; filename="${file.filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
mockCreateErrorResponse.mockImplementation((error: Error) => {
|
||||
return new Response(JSON.stringify({ error: error.name, message: error.message }), {
|
||||
status: error.name === 'FileNotFoundError' ? 404 : 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should serve local file successfully', async () => {
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/test-file.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'test-file.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain')
|
||||
const disposition = response.headers.get('Content-Disposition')
|
||||
expect(disposition).toContain('inline')
|
||||
expect(disposition).toContain('filename=')
|
||||
expect(disposition).toContain('test-file.txt')
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle nested paths correctly', async () => {
|
||||
mockFindLocalFile.mockReturnValue('/test/uploads/nested/path/file.txt')
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nested-path-file.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'nested-path-file.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/test/uploads/nested/path/file.txt')
|
||||
})
|
||||
|
||||
it('should serve cloud file by downloading and proxying', async () => {
|
||||
mockIsUsingCloudStorage.mockReturnValue(true)
|
||||
storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('test cloud file content'))
|
||||
mockGetContentType.mockReturnValue('image/png')
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/1234567890-image.png'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', '1234567890-image.png'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/png')
|
||||
|
||||
expect(storageServiceMockFns.mockDownloadFile).toHaveBeenCalledWith({
|
||||
key: 'workspace/test-workspace-id/1234567890-image.png',
|
||||
context: 'workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 when file not found', async () => {
|
||||
mockVerifyFileAccess.mockResolvedValue(false)
|
||||
mockFindLocalFile.mockReturnValue(null)
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/files/serve/workspace/test-workspace-id/nonexistent.txt'
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', 'nonexistent.txt'] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const responseData = await response.json()
|
||||
expect(responseData).toEqual({
|
||||
error: 'FileNotFoundError',
|
||||
message: expect.stringContaining('File not found'),
|
||||
})
|
||||
})
|
||||
|
||||
describe('content type detection', () => {
|
||||
const contentTypeTests = [
|
||||
{ ext: 'pdf', contentType: 'application/pdf' },
|
||||
{ ext: 'json', contentType: 'application/json' },
|
||||
{ ext: 'jpg', contentType: 'image/jpeg' },
|
||||
{ ext: 'txt', contentType: 'text/plain' },
|
||||
{ ext: 'unknown', contentType: 'application/octet-stream' },
|
||||
]
|
||||
|
||||
for (const test of contentTypeTests) {
|
||||
it(`should serve ${test.ext} file with correct content type`, async () => {
|
||||
mockGetContentType.mockReturnValue(test.contentType)
|
||||
mockFindLocalFile.mockReturnValue(`/test/uploads/file.${test.ext}`)
|
||||
mockCreateFileResponse.mockImplementation(
|
||||
(obj: { buffer: Buffer; contentType: string; filename: string }) =>
|
||||
new Response(obj.buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': obj.contentType,
|
||||
'Content-Disposition': `inline; filename="${obj.filename}"`,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const req = new NextRequest(
|
||||
`http://localhost:3000/api/files/serve/workspace/test-workspace-id/file.${test.ext}`
|
||||
)
|
||||
const params = { path: ['workspace', 'test-workspace-id', `file.${test.ext}`] }
|
||||
|
||||
const response = await GET(req, { params: Promise.resolve(params) })
|
||||
|
||||
expect(response.headers.get('Content-Type')).toBe(test.contentType)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user