chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
authMockFns,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
storageServiceMock,
|
||||
storageServiceMockFns,
|
||||
} from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckStorageQuota, mockGenerateWorkspaceFileKey, mockUseBlobStorage } = vi.hoisted(
|
||||
() => ({
|
||||
mockCheckStorageQuota: vi.fn(),
|
||||
mockGenerateWorkspaceFileKey: vi.fn(),
|
||||
mockUseBlobStorage: { value: false },
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/billing/storage', () => ({
|
||||
checkStorageQuota: mockCheckStorageQuota,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock)
|
||||
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
generateWorkspaceFileKey: mockGenerateWorkspaceFileKey,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/uploads/config', () => ({
|
||||
get USE_BLOB_STORAGE() {
|
||||
return mockUseBlobStorage.value
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
|
||||
|
||||
import { POST } from '@/app/api/workspaces/[id]/files/presigned/route'
|
||||
|
||||
const params = (id = WS) => ({ params: Promise.resolve({ id }) })
|
||||
|
||||
const makeRequest = (body: unknown) =>
|
||||
new NextRequest(`http://localhost/api/workspaces/${WS}/files/presigned`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const validBody = {
|
||||
fileName: 'video.mp4',
|
||||
contentType: 'video/mp4',
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
}
|
||||
|
||||
describe('POST /api/workspaces/[id]/files/presigned', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValue(true)
|
||||
mockGenerateWorkspaceFileKey.mockReturnValue(`workspace/${WS}/123-abc-video.mp4`)
|
||||
storageServiceMockFns.mockGeneratePresignedUploadUrl.mockResolvedValue({
|
||||
url: 'https://s3/presigned',
|
||||
key: `workspace/${WS}/123-abc-video.mp4`,
|
||||
uploadHeaders: { 'Content-Type': 'video/mp4' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
authMockFns.mockGetSession.mockResolvedValueOnce(null)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user has read-only permission', async () => {
|
||||
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 for missing fileName', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileName: '' }), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 for negative fileSize', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileSize: -1 }), params())
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts fileSize === 0 (empty new files)', async () => {
|
||||
const res = await POST(makeRequest({ ...validBody, fileSize: 0 }), params())
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 413 when fileSize exceeds 5 GiB ceiling', async () => {
|
||||
const res = await POST(
|
||||
makeRequest({ ...validBody, fileSize: 6 * 1024 * 1024 * 1024 }),
|
||||
params()
|
||||
)
|
||||
expect(res.status).toBe(413)
|
||||
})
|
||||
|
||||
it('returns 413 when storage quota would be exceeded', async () => {
|
||||
mockCheckStorageQuota.mockResolvedValueOnce({ allowed: false, error: 'Over quota' })
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(413)
|
||||
expect(body.error).toBe('Over quota')
|
||||
})
|
||||
|
||||
it('returns local fallback signal when cloud storage is not configured', async () => {
|
||||
storageServiceMockFns.mockHasCloudStorage.mockReturnValueOnce(false)
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.directUploadSupported).toBe(false)
|
||||
expect(body.presignedUrl).toBe('')
|
||||
expect(body.fileInfo.name).toBe('video.mp4')
|
||||
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('issues a presigned URL bound to the workspace', async () => {
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.directUploadSupported).toBe(true)
|
||||
expect(body.presignedUrl).toBe('https://s3/presigned')
|
||||
expect(body.fileInfo.key).toBe(`workspace/${WS}/123-abc-video.mp4`)
|
||||
expect(body.fileInfo.path).toContain('?context=workspace')
|
||||
expect(body.fileInfo.path).toContain('s3')
|
||||
expect(body.uploadHeaders).toEqual({ 'Content-Type': 'video/mp4' })
|
||||
|
||||
expect(mockGenerateWorkspaceFileKey).toHaveBeenCalledWith(WS, 'video.mp4')
|
||||
expect(storageServiceMockFns.mockGeneratePresignedUploadUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: 'workspace',
|
||||
userId: 'user-1',
|
||||
customKey: `workspace/${WS}/123-abc-video.mp4`,
|
||||
metadata: { workspaceId: WS },
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('serves blob path when blob storage is configured', async () => {
|
||||
mockUseBlobStorage.value = true
|
||||
try {
|
||||
const res = await POST(makeRequest(validBody), params())
|
||||
const body = await res.json()
|
||||
expect(body.fileInfo.path).toContain('/blob/')
|
||||
} finally {
|
||||
mockUseBlobStorage.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { workspacePresignedUploadContract } from '@/lib/api/contracts/workspace-files'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkStorageQuota } from '@/lib/billing/storage'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
|
||||
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
|
||||
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
|
||||
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspacePresignedAPI')
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/[id]/files/presigned
|
||||
* Returns a presigned PUT URL for a workspace-scoped object key. The client
|
||||
* uploads the bytes directly to S3/Blob, then calls /files/register to
|
||||
* insert metadata.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
const parsed = await parseRequest(workspacePresignedUploadContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { params, body } = parsed.data
|
||||
const workspaceId = params.id
|
||||
const { fileName, contentType, fileSize, folderId } = body
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
logger.warn(`User ${userId} lacks write permission for ${workspaceId}`)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (fileSize > MAX_WORKSPACE_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes` },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
let targetFolderId: string | null
|
||||
try {
|
||||
targetFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Invalid target folder') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasCloudStorage()) {
|
||||
logger.info(`Local storage detected, signaling API fallback for ${fileName}`)
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: '',
|
||||
fileInfo: { path: '', key: '', name: fileName, size: fileSize, type: contentType },
|
||||
directUploadSupported: false,
|
||||
})
|
||||
}
|
||||
|
||||
const quotaCheck = await checkStorageQuota(userId, fileSize)
|
||||
if (!quotaCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quotaCheck.error || 'Storage limit exceeded' },
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const key = generateWorkspaceFileKey(workspaceId, fileName)
|
||||
const presigned = await generatePresignedUploadUrl({
|
||||
fileName,
|
||||
contentType,
|
||||
fileSize,
|
||||
context: 'workspace',
|
||||
userId,
|
||||
customKey: key,
|
||||
expirationSeconds: 3600,
|
||||
metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
|
||||
})
|
||||
|
||||
const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace`
|
||||
|
||||
logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`)
|
||||
|
||||
return NextResponse.json({
|
||||
fileName,
|
||||
presignedUrl: presigned.url,
|
||||
fileInfo: {
|
||||
path: finalPath,
|
||||
key: presigned.key,
|
||||
name: fileName,
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
},
|
||||
uploadHeaders: presigned.uploadHeaders,
|
||||
directUploadSupported: true,
|
||||
})
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user