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
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"fumadocs-openapi": "10.8.1",
"fumadocs-ui": "16.8.5",
"lucide-react": "^0.511.0",
"next": "16.2.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
"postgres": "^3.4.5",
"react": "19.2.4",
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/app/(landing)/seo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,21 @@ const SEO_SCAN_DIRS = [

const SEO_SCAN_INDIVIDUAL_FILES = [
path.resolve(APP_DIR, 'page.tsx'),
path.resolve(APP_DIR, 'robots.ts'),
path.resolve(APP_DIR, 'sitemap.ts'),
path.resolve(SIM_ROOT, 'ee', 'whitelabeling', 'metadata.ts'),
]

/**
* Files whose entire URL output is SEO-facing (robots.txt, sitemap.xml).
* Unlike metadata exports, these don't use `metadataBase`, so the existing
* `getBaseUrl()`-in-metadata check would miss a regression here.
*/
const SEO_DEFAULT_EXPORT_FILES = [
path.resolve(APP_DIR, 'robots.ts'),
path.resolve(APP_DIR, 'sitemap.ts'),
]

function collectFiles(dir: string, exts: string[]): string[] {
const results: string[] = []
if (!fs.existsSync(dir)) return results
Expand Down Expand Up @@ -97,6 +109,21 @@ describe('SEO canonical URLs', () => {
).toHaveLength(0)
})

it('robots.ts and sitemap.ts do not import getBaseUrl', () => {
const violations: string[] = []
for (const file of SEO_DEFAULT_EXPORT_FILES) {
if (!fs.existsSync(file)) continue
const content = fs.readFileSync(file, 'utf-8')
if (content.includes('getBaseUrl')) {
violations.push(path.relative(SIM_ROOT, file))
}
}
expect(
violations,
`robots.ts/sitemap.ts must use SITE_URL, not getBaseUrl():\n${violations.join('\n')}`
).toHaveLength(0)
})

it('public pages do not use getBaseUrl() for SEO metadata', () => {
const files = getAllSeoFiles(['.ts', '.tsx'])
const violations: string[] = []
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/app/api/files/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import { isUuid } from '@/executor/constants'

const logger = createLogger('FileAuthorization')

/** Thrown by utility functions when file access is denied, so route handlers can return 404. */
export class FileAccessDeniedError extends Error {
constructor() {
super('File not found')
this.name = 'FileAccessDeniedError'
}
}

interface AuthorizationResult {
granted: boolean
reason: string
Expand Down Expand Up @@ -598,18 +606,14 @@ async function authorizeFileAccess(
*/
export async function assertToolFileAccess(
key: unknown,
userId: string | undefined,
userId: string,
requestId: string,
routeLogger: ReturnType<typeof createLogger>
): Promise<NextResponse | null> {
if (typeof key !== 'string' || key.length === 0) {
routeLogger.warn(`[${requestId}] File access check rejected: missing key`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
if (!userId) {
routeLogger.warn(`[${requestId}] File access check requires userId but none available`)
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
}
const hasAccess = await verifyFileAccess(key, userId)
if (!hasAccess) {
routeLogger.warn(`[${requestId}] File access denied for user`, { userId, key })
Expand Down
75 changes: 75 additions & 0 deletions apps/sim/app/api/files/multipart/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({

vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
mockCheckStorageQuota: vi.fn(),
mockInitiateS3MultipartUpload: vi.fn(),
}))

vi.mock('@/lib/billing/storage', () => ({
checkStorageQuota: mockCheckStorageQuota,
}))

import { POST } from '@/app/api/files/multipart/route'

const tokenPayload = {
Expand Down Expand Up @@ -200,3 +209,69 @@ describe('POST /api/files/multipart action=complete', () => {
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
})
})

describe('POST /api/files/multipart action=initiate quota enforcement', () => {
const makeInitiateRequest = (body: unknown) =>
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})

beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockIsUsingCloudStorage.mockReturnValue(true)
mockGetStorageProvider.mockReturnValue('s3')
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockSignUploadToken.mockReturnValue('signed-token')
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
})

it('blocks upload when fileSize: 0 exceeds quota', async () => {
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })

const res = await makeInitiateRequest({
fileName: 'file.bin',
contentType: 'application/octet-stream',
fileSize: 0,
workspaceId: 'ws-1',
context: 'knowledge-base',
})

const response = await POST(res)
expect(response.status).toBe(413)
const body = await response.json()
expect(body.error).toContain('Storage limit exceeded')
})

it('does not check quota for quota-exempt contexts (og-images)', async () => {
const res = await makeInitiateRequest({
fileName: 'img.png',
contentType: 'image/png',
fileSize: 99999,
workspaceId: 'ws-1',
context: 'og-images',
})

const response = await POST(res)
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
})

it('rejects logs context — not allowed via the multipart endpoint', async () => {
const res = await makeInitiateRequest({
fileName: 'exec.log',
contentType: 'text/plain',
fileSize: 1000,
workspaceId: 'ws-1',
context: 'logs',
})

const response = await POST(res)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toMatch(/invalid storage context/i)
})
})
23 changes: 12 additions & 11 deletions apps/sim/app/api/files/multipart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
type UploadTokenPayload,
verifyUploadToken,
} from '@/lib/uploads/core/upload-token'
import type { StorageConfig } from '@/lib/uploads/shared/types'
import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('MultipartUploadAPI')
Expand All @@ -36,7 +36,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
'workspace',
'profile-pictures',
'og-images',
'logs',
'workspace-logos',
])

Expand Down Expand Up @@ -135,6 +134,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const config = getStorageConfig(storageContext)

if (!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext)) {
const { checkStorageQuota } = await import('@/lib/billing/storage')
const quotaCheck = await checkStorageQuota(userId, fileSize ?? 0)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

let customKey: string | undefined
if (context === 'workspace' || context === 'mothership') {
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
Expand All @@ -149,15 +159,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
customKey = generateWorkspaceFileKey(workspaceId, fileName)

const { checkStorageQuota } = await import('@/lib/billing/storage')
const quotaCheck = await checkStorageQuota(userId, fileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
} else if (context === 'execution') {
const workflowId = (data as { workflowId?: unknown }).workflowId
const executionId = (data as { executionId?: unknown }).executionId
Expand Down
Loading
Loading