diff --git a/apps/docs/package.json b/apps/docs/package.json index d614fe60b0a..49f658b42a5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/sim/app/(landing)/seo.test.ts b/apps/sim/app/(landing)/seo.test.ts index cb7b207af05..9168c896b95 100644 --- a/apps/sim/app/(landing)/seo.test.ts +++ b/apps/sim/app/(landing)/seo.test.ts @@ -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 @@ -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[] = [] diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index ef5183ae0da..07ec36261b4 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -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 @@ -598,7 +606,7 @@ async function authorizeFileAccess( */ export async function assertToolFileAccess( key: unknown, - userId: string | undefined, + userId: string, requestId: string, routeLogger: ReturnType ): Promise { @@ -606,10 +614,6 @@ export async function assertToolFileAccess( 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 }) diff --git a/apps/sim/app/api/files/multipart/route.test.ts b/apps/sim/app/api/files/multipart/route.test.ts index b70fed81b82..520a05dd065 100644 --- a/apps/sim/app/api/files/multipart/route.test.ts +++ b/apps/sim/app/api/files/multipart/route.test.ts @@ -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 = { @@ -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) + }) +}) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 836fc40ad6b..bf4d9fd8276 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -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') @@ -36,7 +36,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([ 'workspace', 'profile-pictures', 'og-images', - 'logs', 'workspace-logos', ]) @@ -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 } + ) + } + } + let customKey: string | undefined if (context === 'workspace' || context === 'mothership') { const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types') @@ -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 diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 9c86fc1c4f6..7891fcd01b1 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -21,9 +21,9 @@ export const dynamic = 'force-dynamic' export const maxDuration = 3600 const logger = createLogger('ScheduledExecuteAPI') -const MAX_CRON_CLAIMS = 200 -const RESERVED_WORKFLOW_CLAIMS = 100 -const RESERVED_JOB_CLAIMS = MAX_CRON_CLAIMS - RESERVED_WORKFLOW_CLAIMS +const WORKFLOW_CHUNK_SIZE = 100 +const JOB_CHUNK_SIZE = 100 +const MAX_TICK_DURATION_MS = 3 * 60 * 1000 const STALE_SCHEDULE_CLAIM_MS = getMaxExecutionTimeout() const dueFilter = (queuedAt: Date) => @@ -143,203 +143,240 @@ async function claimJobSchedules(queuedAt: Date, limit: number) { }) } -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - - const authError = verifyCronAuth(request, 'Schedule execution') - if (authError) { - return authError +type ClaimedSchedule = Awaited>[number] +type ClaimedJob = Awaited>[number] +type WorkflowUtils = typeof import('@/lib/workflows/utils') +type JobQueue = Awaited> + +async function processScheduleItem( + schedule: ClaimedSchedule, + queuedAt: Date, + requestId: string, + jobQueue: JobQueue, + workflowUtils: WorkflowUtils +) { + const queueTime = schedule.lastQueuedAt ?? queuedAt + const executionId = generateId() + const correlation = { + executionId, + requestId, + source: 'schedule' as const, + workflowId: schedule.workflowId!, + scheduleId: schedule.id, + triggerType: 'schedule', + scheduledFor: schedule.nextRunAt?.toISOString(), } - const queuedAt = new Date() + const payload = { + scheduleId: schedule.id, + workflowId: schedule.workflowId!, + executionId, + requestId, + correlation, + blockId: schedule.blockId || undefined, + deploymentVersionId: schedule.deploymentVersionId || undefined, + cronExpression: schedule.cronExpression || undefined, + lastRanAt: schedule.lastRanAt?.toISOString(), + failedCount: schedule.failedCount || 0, + now: queueTime.toISOString(), + scheduledFor: schedule.nextRunAt?.toISOString(), + } try { - const dueSchedules = await claimWorkflowSchedules(queuedAt, RESERVED_WORKFLOW_CLAIMS) - const dueJobs = await claimJobSchedules(queuedAt, RESERVED_JOB_CLAIMS) - const remainingClaimBudget = Math.max(0, MAX_CRON_CLAIMS - dueSchedules.length - dueJobs.length) - - if (remainingClaimBudget > 0 && dueSchedules.length === RESERVED_WORKFLOW_CLAIMS) { - dueSchedules.push(...(await claimWorkflowSchedules(queuedAt, remainingClaimBudget))) - } else if (remainingClaimBudget > 0 && dueJobs.length === RESERVED_JOB_CLAIMS) { - dueJobs.push(...(await claimJobSchedules(queuedAt, remainingClaimBudget))) + const scheduleJobId = buildScheduleExecutionJobId(schedule) + const existingJob = await jobQueue.getJob(scheduleJobId) + if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { + logger.info(`[${requestId}] Schedule execution job already exists`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + return + } + if (existingJob) { + logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { + scheduleId: schedule.id, + jobId: scheduleJobId, + status: existingJob.status, + }) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return } - const totalCount = dueSchedules.length + dueJobs.length + const resolvedWorkflow = schedule.workflowId + ? await workflowUtils.getWorkflowById(schedule.workflowId) + : null + const resolvedWorkspaceId = resolvedWorkflow?.workspaceId + + const jobId = await jobQueue.enqueue('schedule-execution', payload, { + jobId: scheduleJobId, + concurrencyKey: scheduleJobId, + metadata: { + workflowId: schedule.workflowId ?? undefined, + workspaceId: resolvedWorkspaceId ?? undefined, + correlation, + }, + }) logger.info( - `[${requestId}] Processing ${totalCount} due items (${dueSchedules.length} schedules, ${dueJobs.length} jobs)` + `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` ) - const jobQueue = await getJobQueue() - - const workflowUtils = - dueSchedules.length > 0 ? await import('@/lib/workflows/utils') : undefined - - const schedulePromises = dueSchedules.map(async (schedule) => { - const queueTime = schedule.lastQueuedAt ?? queuedAt - const executionId = generateId() - const correlation = { - executionId, - requestId, - source: 'schedule' as const, - workflowId: schedule.workflowId!, - scheduleId: schedule.id, - triggerType: 'schedule', - scheduledFor: schedule.nextRunAt?.toISOString(), - } - - const payload = { + const queuedJob = await jobQueue.getJob(jobId) + if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { + logger.info(`[${requestId}] Schedule execution job already finished`, { scheduleId: schedule.id, - workflowId: schedule.workflowId!, - executionId, + jobId, + status: queuedJob.status, + }) + await releaseScheduleLock( + schedule.id, requestId, - correlation, - blockId: schedule.blockId || undefined, - deploymentVersionId: schedule.deploymentVersionId || undefined, - cronExpression: schedule.cronExpression || undefined, - lastRanAt: schedule.lastRanAt?.toISOString(), - failedCount: schedule.failedCount || 0, - now: queueTime.toISOString(), - scheduledFor: schedule.nextRunAt?.toISOString(), - } + queuedAt, + `Released stale schedule ${schedule.id} for finished job ${jobId}`, + getNextRunFromCronExpression(schedule.cronExpression) + ) + return + } + if (shouldExecuteInline()) { try { - const scheduleJobId = buildScheduleExecutionJobId(schedule) - const existingJob = await jobQueue.getJob(scheduleJobId) - if (existingJob && ['pending', 'processing'].includes(existingJob.status)) { - logger.info(`[${requestId}] Schedule execution job already exists`, { - scheduleId: schedule.id, - jobId: scheduleJobId, - status: existingJob.status, - }) - return - } - if (existingJob) { - logger.info(`[${requestId}] Releasing stale schedule claim for finished job`, { - scheduleId: schedule.id, - jobId: scheduleJobId, - status: existingJob.status, - }) - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Released stale schedule ${schedule.id} for finished job ${scheduleJobId}`, - getNextRunFromCronExpression(schedule.cronExpression) - ) - return - } - - const resolvedWorkflow = schedule.workflowId - ? await workflowUtils?.getWorkflowById(schedule.workflowId) - : null - const resolvedWorkspaceId = resolvedWorkflow?.workspaceId - - const jobId = await jobQueue.enqueue('schedule-execution', payload, { - jobId: scheduleJobId, - concurrencyKey: scheduleJobId, - metadata: { - workflowId: schedule.workflowId ?? undefined, - workspaceId: resolvedWorkspaceId ?? undefined, - correlation, - }, - }) - logger.info( - `[${requestId}] Queued schedule execution task ${jobId} for workflow ${schedule.workflowId}` + await jobQueue.startJob(jobId) + const output = await executeScheduleJob(payload) + await jobQueue.completeJob(jobId, output) + } catch (error) { + const errorMessage = toError(error).message + logger.error( + `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, + { + jobId, + error: errorMessage, + } ) - - const queuedJob = await jobQueue.getJob(jobId) - if (queuedJob && !['pending', 'processing'].includes(queuedJob.status)) { - logger.info(`[${requestId}] Schedule execution job already finished`, { - scheduleId: schedule.id, + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + logger.error(`[${requestId}] Failed to mark job as failed`, { jobId, - status: queuedJob.status, + error: toError(markFailedError).message, }) - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Released stale schedule ${schedule.id} for finished job ${jobId}`, - getNextRunFromCronExpression(schedule.cronExpression) - ) - return } - - if (shouldExecuteInline()) { - try { - await jobQueue.startJob(jobId) - const output = await executeScheduleJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = toError(error).message - logger.error( - `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, - { - jobId, - error: errorMessage, - } - ) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - logger.error(`[${requestId}] Failed to mark job as failed`, { - jobId, - error: - markFailedError instanceof Error - ? markFailedError.message - : String(markFailedError), - }) - } - await releaseScheduleLock( - schedule.id, - requestId, - queuedAt, - `Failed to release lock for schedule ${schedule.id} after inline execution failure` - ) - } - } - } catch (error) { - logger.error( - `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, - error - ) await releaseScheduleLock( schedule.id, requestId, queuedAt, - `Failed to release lock for schedule ${schedule.id} after queue failure` + `Failed to release lock for schedule ${schedule.id} after inline execution failure` ) } + } + } catch (error) { + logger.error( + `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, + error + ) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Failed to release lock for schedule ${schedule.id} after queue failure` + ) + } +} + +async function processJobItem(job: ClaimedJob, queuedAt: Date, requestId: string) { + const queueTime = job.lastQueuedAt ?? queuedAt + const payload = { + scheduleId: job.id, + cronExpression: job.cronExpression || undefined, + failedCount: job.failedCount || 0, + now: queueTime.toISOString(), + } + + try { + await executeJobInline(payload) + } catch (error) { + logger.error(`[${requestId}] Job execution failed for ${job.id}`, { + error: toError(error).message, }) + await releaseScheduleLock( + job.id, + requestId, + queuedAt, + `Failed to release lock for job ${job.id}` + ) + } +} - // Mothership jobs are executed inline directly. - const jobPromises = dueJobs.map(async (job) => { - const queueTime = job.lastQueuedAt ?? queuedAt - const payload = { - scheduleId: job.id, - cronExpression: job.cronExpression || undefined, - failedCount: job.failedCount || 0, - now: queueTime.toISOString(), - } +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickStart = Date.now() + logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) - try { - await executeJobInline(payload) - } catch (error) { - logger.error(`[${requestId}] Job execution failed for ${job.id}`, { - error: toError(error).message, - }) - await releaseScheduleLock( - job.id, - requestId, - queuedAt, - `Failed to release lock for job ${job.id}` - ) + const authError = verifyCronAuth(request, 'Schedule execution') + if (authError) { + return authError + } + + try { + const jobQueue = await getJobQueue() + let workflowUtils: WorkflowUtils | undefined + + let totalSchedules = 0 + let totalJobs = 0 + let iterations = 0 + let schedulesExhausted = false + let jobsExhausted = false + + while (Date.now() - tickStart < MAX_TICK_DURATION_MS) { + if (schedulesExhausted && jobsExhausted) break + const queuedAt = new Date() + + const [dueSchedules, dueJobs] = await Promise.all([ + schedulesExhausted ? [] : claimWorkflowSchedules(queuedAt, WORKFLOW_CHUNK_SIZE), + jobsExhausted ? [] : claimJobSchedules(queuedAt, JOB_CHUNK_SIZE), + ]) + + if (dueSchedules.length < WORKFLOW_CHUNK_SIZE) schedulesExhausted = true + if (dueJobs.length < JOB_CHUNK_SIZE) jobsExhausted = true + + if (dueSchedules.length === 0 && dueJobs.length === 0) break + + iterations += 1 + totalSchedules += dueSchedules.length + totalJobs += dueJobs.length + + logger.info( + `[${requestId}] Iteration ${iterations}: claimed ${dueSchedules.length} schedules, ${dueJobs.length} jobs` + ) + + if (dueSchedules.length > 0 && !workflowUtils) { + workflowUtils = await import('@/lib/workflows/utils') } - }) - await Promise.allSettled([...schedulePromises, ...jobPromises]) + const loadedWorkflowUtils = workflowUtils + const schedulePromises = + loadedWorkflowUtils && dueSchedules.length > 0 + ? dueSchedules.map((schedule) => + processScheduleItem(schedule, queuedAt, requestId, jobQueue, loadedWorkflowUtils) + ) + : [] + + await Promise.allSettled([ + ...schedulePromises, + ...dueJobs.map((job) => processJobItem(job, queuedAt, requestId)), + ]) + } - logger.info(`[${requestId}] Processed ${totalCount} items`) + const totalCount = totalSchedules + totalJobs + const durationMs = Date.now() - tickStart + logger.info( + `[${requestId}] Processed ${totalCount} items across ${iterations} iteration(s) in ${durationMs}ms (${totalSchedules} schedules, ${totalJobs} jobs)` + ) return NextResponse.json({ message: 'Scheduled workflow executions processed', diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index edcbdc4c0f3..6257502ae4c 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils' export const dynamic = 'force-dynamic' @@ -22,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -66,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { `[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)` ) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const resolvedFileName = data.fileName || userFile.name || 'attachment' diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts index 9bd50e77634..73519befcd7 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -49,6 +50,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = validatedData.fileName || userFile.name } else if (validatedData.fileContent) { diff --git a/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts new file mode 100644 index 00000000000..b7d435a6e46 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts @@ -0,0 +1,62 @@ +import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchMuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-mute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchMuteAlarm') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchMuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new DisableAlarmActionsCommand({ + AlarmNames: validatedData.alarmNames, + }) + + await client.send(command) + + logger.info(`Successfully muted ${validatedData.alarmNames.length} alarm(s)`) + + return NextResponse.json({ + success: true, + output: { + success: true, + alarmNames: validatedData.alarmNames, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('MuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to mute CloudWatch alarm: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts new file mode 100644 index 00000000000..79357ae38f1 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts @@ -0,0 +1,62 @@ +import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('CloudWatchUnmuteAlarm') + +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(awsCloudwatchUnmuteAlarmContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const validatedData = parsed.data.body + + logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + + const client = new CloudWatchClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + try { + const command = new EnableAlarmActionsCommand({ + AlarmNames: validatedData.alarmNames, + }) + + await client.send(command) + + logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`) + + return NextResponse.json({ + success: true, + output: { + success: true, + alarmNames: validatedData.alarmNames, + }, + }) + } finally { + client.destroy() + } + } catch (error) { + logger.error('UnmuteAlarm failed', { error: toError(error).message }) + return NextResponse.json( + { error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index 713ee1a011a..32ae35a0a27 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -7,6 +7,7 @@ import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -80,6 +81,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess( + userFile.key, + auth.userId, + 'confluence-upload', + logger + ) + if (denied) return denied + let fileBuffer: Buffer try { fileBuffer = await downloadFileFromStorage(userFile, 'confluence-upload', logger) diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index 061fec94de7..9a4c24096d4 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Discord send attempt: ${authResult.error}`) return NextResponse.json( { @@ -30,8 +31,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Discord send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(discordSendMessageContract, request, {}) @@ -134,17 +136,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } formData.append('payload_json', JSON.stringify(payload)) - const downloadedFiles = await Promise.all( - userFiles.map(async (userFile, i) => { - logger.info(`[${requestId}] Downloading file ${i}: ${userFile.name}`) - const buffer = await downloadFileFromStorage(userFile, requestId, logger) - logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) - return { userFile, buffer } + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + userFiles.map(async (file, i) => { + try { + logger.info(`[${requestId}] Downloading file ${i}: ${file.name}`) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } }) ) - for (let i = 0; i < downloadedFiles.length; i++) { - const { userFile, buffer } = downloadedFiles[i] + for (let i = 0; i < userFiles.length; i++) { + const userFile = userFiles[i] + const buffer = buffers[i] + logger.info(`[${requestId}] Added file ${i}: ${userFile.name} (${buffer.length} bytes)`) filesOutput.push({ name: userFile.name, mimeType: userFile.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts index a1352435b6f..04c363def3b 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -7,6 +7,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('DocuSignAPI') @@ -54,7 +55,7 @@ async function resolveAccount(accessToken: string): Promise export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } @@ -84,7 +85,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { switch (operation) { case 'send_envelope': - return await handleSendEnvelope(apiBase, headers, params) + return await handleSendEnvelope(apiBase, headers, params, authResult.userId) case 'create_from_template': return await handleCreateFromTemplate(apiBase, headers, params) case 'get_envelope': @@ -115,7 +116,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { async function handleSendEnvelope( apiBase: string, headers: Record, - params: Record + params: Record, + userId: string ) { const { signerEmail, signerName, emailSubject, emailBody, ccEmail, ccName, file, status } = params @@ -135,6 +137,8 @@ async function handleSendEnvelope( const userFiles = processFilesToUserFiles([parsed as RawFileInput], 'docusign-send', logger) if (userFiles.length > 0) { const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, userId, 'docusign-send', logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, 'docusign-send', logger) documentBase64 = buffer.toString('base64') documentName = userFile.name diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts index 055ccb140ae..2c14fcd0dc6 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -8,6 +8,7 @@ import { httpHeaderSafeJson } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dropbox upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -52,6 +53,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) fileName = userFile.name } else if (validatedData.fileContent) { diff --git a/apps/sim/app/api/tools/firecrawl/parse/route.ts b/apps/sim/app/api/tools/firecrawl/parse/route.ts index 1c46e85651e..409f74a6f16 100644 --- a/apps/sim/app/api/tools/firecrawl/parse/route.ts +++ b/apps/sim/app/api/tools/firecrawl/parse/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -43,6 +44,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: userFile.size, }) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const formData = new FormData() diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index bbd5ee5d94d..1b51ff59d28 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -26,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -37,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail draft request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(gmailDraftContract, request, {}) @@ -85,20 +87,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -108,6 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/gmail/edit-draft/route.ts b/apps/sim/app/api/tools/gmail/edit-draft/route.ts index a9515aff73d..dc8b3e71785 100644 --- a/apps/sim/app/api/tools/gmail/edit-draft/route.ts +++ b/apps/sim/app/api/tools/gmail/edit-draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -25,7 +26,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail edit draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -36,9 +37,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Gmail edit draft request via ${authResult.authType}`, - { userId: authResult.userId } + { userId } ) const parsed = await parseRequest(gmailEditDraftContract, request, {}) @@ -81,17 +83,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { - const buffer = await downloadFileFromStorage(file, requestId, logger) - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) } }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 028216e6283..d0e6d1b6401 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { base64UrlEncode, buildMimeMessage, @@ -26,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Gmail send attempt: ${authResult.error}`) return NextResponse.json( { @@ -37,8 +38,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Gmail send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(gmailSendContract, request, {}) @@ -85,20 +87,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - filename: file.name, - mimeType: file.type || 'application/octet-stream', - content: buffer, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -108,6 +109,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + mimeType: file.type || 'application/octet-stream', + content: buffers[i], + })) + const mimeMessage = buildMimeMessage({ to: validatedData.to, cc: validatedData.cc ?? undefined, diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 0222cf03b55..797d7cf11d2 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { GOOGLE_WORKSPACE_MIME_TYPES, handleSheetsFormat, @@ -52,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Google Drive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -113,6 +114,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { size: userFile.size, }) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + let fileBuffer: Buffer try { diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index e889ef13745..6f343879d33 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' const logger = createLogger('JiraAddAttachmentAPI') @@ -17,7 +18,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json( { success: false, error: authResult.error || 'Unauthorized' }, { status: 401 } @@ -43,6 +44,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const formData = new FormData() for (const file of userFiles) { + const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(file, requestId, logger) const blob = new Blob([new Uint8Array(buffer)], { type: file.type || 'application/octet-stream', diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index 31bf0d8fe07..2bfca548e7c 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Dataverse upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -66,6 +67,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } else if (validatedData.fileContent) { fileBuffer = Buffer.from(validatedData.fileContent, 'base64') diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index 697497d1357..208261b9b8a 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { FileAccessDeniedError } from '@/app/api/files/authorization' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -20,7 +21,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams channel write attempt: ${authResult.error}`) return NextResponse.json( { @@ -31,10 +32,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams channel write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) @@ -54,6 +56,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -160,6 +163,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams channel message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 964247ed303..ce350752da7 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { FileAccessDeniedError } from '@/app/api/files/authorization' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils' @@ -20,7 +21,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Teams chat write attempt: ${authResult.error}`) return NextResponse.json( { @@ -31,10 +32,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info( `[${requestId}] Authenticated Teams chat write request via ${authResult.authType}`, { - userId: authResult.userId, + userId, } ) @@ -53,6 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, requestId, logger, + userId, }) let messageContent = validatedData.content @@ -157,6 +160,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Teams chat message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index d5334ebf851..c684b9c4492 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -14,6 +14,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -120,6 +121,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } let base64 = userFile.base64 if (!base64) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') } diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 54aafbc0c03..5913cff16e2 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -13,6 +13,7 @@ import { processSingleFileToUserFile, } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { normalizeExcelValues } from '@/tools/onedrive/utils' export const dynamic = 'force-dynamic' @@ -47,7 +48,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized OneDrive upload attempt: ${authResult.error}`) return NextResponse.json( { @@ -108,6 +109,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + try { fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) } catch (error) { diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 3b2bf4aec1f..65bec69895d 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook draft attempt: ${authResult.error}`) return NextResponse.json( { @@ -29,8 +30,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook draft request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(outlookDraftContract, request, {}) @@ -98,23 +100,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -124,6 +122,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index f30f47b13b1..cbd9a175786 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Outlook send attempt: ${authResult.error}`) return NextResponse.json( { @@ -29,8 +30,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Outlook send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(outlookSendContract, request, {}) @@ -98,23 +100,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentObjects = await Promise.all( + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( attachments.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - - const buffer = await downloadFileFromStorage(file, requestId, logger) - - const base64Content = buffer.toString('base64') - - return { - '@odata.type': '#microsoft.graph.fileAttachment', - name: file.name, - contentType: file.type || 'application/octet-stream', - contentBytes: base64Content, - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -124,6 +122,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const attachmentObjects = attachments.map((file, i) => ({ + '@odata.type': '#microsoft.graph.fileAttachment', + name: file.name, + contentType: file.type || 'application/octet-stream', + contentBytes: buffers[i].toString('base64'), + })) + logger.info(`[${requestId}] Converted ${attachmentObjects.length} attachments to base64`) message.attachments = attachmentObjects } diff --git a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts index 190c004916b..226e955d848 100644 --- a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts @@ -8,6 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('QuiverImageToSvgAPI') @@ -15,7 +16,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } @@ -47,6 +48,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { @@ -64,6 +72,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof data.image === 'object' && data.image !== null) { const userFiles = processFilesToUserFiles([data.image as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiImage = { base64: buffer.toString('base64') } } else { diff --git a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts index 12b456e75a3..11441e059d8 100644 --- a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts @@ -8,6 +8,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' const logger = createLogger('QuiverTextToSvgAPI') @@ -15,9 +16,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } + const userId = authResult.userId try { const parsed = await parseRequest( @@ -51,6 +53,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (parsed && typeof parsed === 'object') { const userFiles = processFilesToUserFiles([parsed as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess( + userFiles[0].key, + userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } @@ -61,6 +70,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (typeof ref === 'object' && ref !== null) { const userFiles = processFilesToUserFiles([ref as RawFileInput], requestId, logger) if (userFiles.length > 0) { + const denied = await assertToolFileAccess(userFiles[0].key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFiles[0], requestId, logger) apiReferences.push({ base64: buffer.toString('base64') }) } diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index be4ee9fa81a..a2798c4fc17 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized S3 put object attempt: ${authResult.error}`) return NextResponse.json( { @@ -76,6 +77,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer diff --git a/apps/sim/app/api/tools/sap_concur/upload/route.ts b/apps/sim/app/api/tools/sap_concur/upload/route.ts index 81885d9a29f..74f8fb093de 100644 --- a/apps/sim/app/api/tools/sap_concur/upload/route.ts +++ b/apps/sim/app/api/tools/sap_concur/upload/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { assertSafeExternalUrl, extractSapConcurError, @@ -180,13 +181,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Concur upload request: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } ) } + const userId = authResult.userId // boundary-raw-json: internal upload envelope validated by SapConcurUploadRequestSchema below; not a public boundary const json = await request.json() @@ -204,6 +206,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const fileName = userFile.name const mimeType = inferMimeType(fileName, userFile.type) diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts index b4a9844bc97..5c4c13952e9 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SendGrid send attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, @@ -26,6 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SendGrid send request via ${authResult.authType}`) const parsed = await parseRequest(sendGridSendMailContract, request, {}) @@ -97,20 +99,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFiles = processFilesToUserFiles(rawAttachments, requestId, logger) if (userFiles.length > 0) { - const sendGridAttachments = await Promise.all( + const accessResults = await Promise.all( + userFiles.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( userFiles.map(async (file) => { try { logger.info( `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` ) - const buffer = await downloadFileFromStorage(file, requestId, logger) - - return { - content: buffer.toString('base64'), - filename: file.name, - type: file.type || 'application/octet-stream', - disposition: 'attachment', - } + return await downloadFileFromStorage(file, requestId, logger) } catch (error) { logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) throw new Error( @@ -120,6 +121,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) ) + const sendGridAttachments = userFiles.map((file, i) => ({ + content: buffers[i].toString('base64'), + filename: file.name, + type: file.type || 'application/octet-stream', + disposition: 'attachment', + })) + mailBody.attachments = sendGridAttachments } } diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index 8acf93ca585..b7198ee4368 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -27,7 +27,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SFTP upload attempt: ${authResult.error}`) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index e69c854b026..ce7bcefcbc9 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -1,15 +1,12 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint' import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,11 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - const { searchParams } = new URL(request.url) const validation = sharepointSiteQuerySchema.safeParse({ credentialId: searchParams.get('credentialId') ?? '', @@ -42,37 +34,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) } - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + const authz = await authorizeCredentialUse(request, { credentialId }) + if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - if (resolved.workspaceId) { - const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils') - const perm = await getUserEntityPermissions( - session.user.id, - 'workspace', - resolved.workspaceId - ) - if (perm === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - const credentials = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (!credentials.length) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accountRow = credentials[0] - const accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - accountRow.userId, + authz.resolvedCredentialId, + authz.credentialOwnerUserId, requestId ) if (!accessToken) { diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index 2229d1ecc6a..af975058eb1 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -24,7 +24,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SharePoint upload attempt: ${authResult.error}`) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 2d776a1eb96..7745d22c51f 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -5,7 +5,8 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendSlackMessage } from '../utils' +import { FileAccessDeniedError } from '@/app/api/files/authorization' +import { sendSlackMessage } from '@/app/api/tools/slack/utils' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Slack send attempt: ${authResult.error}`) return NextResponse.json( { @@ -28,8 +29,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated Slack send request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(slackSendMessageContract, request, {}) @@ -50,6 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { accessToken: validatedData.accessToken, channel: validatedData.channel ?? undefined, userId: validatedData.userId ?? undefined, + ownerUserId: userId, text: validatedData.text, threadTs: validatedData.thread_ts ?? undefined, blocks: validatedData.blocks ?? undefined, @@ -65,6 +68,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, output: result.output }) } catch (error) { + if (error instanceof FileAccessDeniedError) { + return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 }) + } logger.error(`[${requestId}] Error sending Slack message:`, error) return NextResponse.json( { diff --git a/apps/sim/app/api/tools/slack/utils.ts b/apps/sim/app/api/tools/slack/utils.ts index d74b078595f..91b6fc14534 100644 --- a/apps/sim/app/api/tools/slack/utils.ts +++ b/apps/sim/app/api/tools/slack/utils.ts @@ -2,6 +2,7 @@ import type { Logger } from '@sim/logger' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization' import type { ToolFileData } from '@/tools/types' /** @@ -73,7 +74,8 @@ async function uploadFilesToSlack( files: any[], accessToken: string, requestId: string, - logger: Logger + logger: Logger, + ownerUserId: string ): Promise<{ fileIds: string[]; files: ToolFileData[] }> { const userFiles = processFilesToUserFiles(files, requestId, logger) const uploadedFileIds: string[] = [] @@ -82,6 +84,11 @@ async function uploadFilesToSlack( for (const userFile of userFiles) { logger.info(`[${requestId}] Uploading file: ${userFile.name}`) + const hasAccess = await verifyFileAccess(userFile.key, ownerUserId) + if (!hasAccess) { + throw new FileAccessDeniedError() + } + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', { @@ -141,7 +148,8 @@ async function completeSlackFileUpload( channel: string, text: string, accessToken: string, - threadTs?: string | null + threadTs?: string | null, + blocks?: unknown[] | null ): Promise<{ ok: boolean; files?: any[]; error?: string }> { const response = await fetch('https://slack.com/api/files.completeUploadExternal', { method: 'POST', @@ -152,7 +160,10 @@ async function completeSlackFileUpload( body: JSON.stringify({ files: uploadedFileIds.map((id) => ({ id })), channel_id: channel, - initial_comment: text, + // Per Slack docs for files.completeUploadExternal: if `initial_comment` + // is provided, `blocks` is silently ignored. So when blocks are present + // we omit initial_comment and let blocks render instead. + ...(blocks && blocks.length > 0 ? { blocks } : { initial_comment: text }), ...(threadTs && { thread_ts: threadTs }), }), }) @@ -220,6 +231,7 @@ export interface SlackMessageParams { accessToken: string channel?: string userId?: string + ownerUserId: string text: string threadTs?: string | null blocks?: unknown[] | null @@ -245,7 +257,7 @@ export async function sendSlackMessage( } error?: string }> { - const { accessToken, text, threadTs, blocks, files } = params + const { accessToken, text, threadTs, blocks, files, ownerUserId } = params let { channel } = params if (!channel && params.userId) { @@ -278,7 +290,8 @@ export async function sendSlackMessage( files, accessToken, requestId, - logger + logger, + ownerUserId ) // No valid files uploaded - send text-only @@ -295,7 +308,14 @@ export async function sendSlackMessage( } // Complete file upload with thread support - const completeData = await completeSlackFileUpload(fileIds, channel, text, accessToken, threadTs) + const completeData = await completeSlackFileUpload( + fileIds, + channel, + text, + accessToken, + threadTs, + blocks + ) if (!completeData.ok) { logger.error(`[${requestId}] Failed to complete upload:`, completeData.error) diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index ea1f5e16d51..499768b407a 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -22,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized SMTP send attempt: ${authResult.error}`) return NextResponse.json( { @@ -33,8 +33,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const userId = authResult.userId logger.info(`[${requestId}] Authenticated SMTP request via ${authResult.authType}`, { - userId: authResult.userId, + userId, }) const parsed = await parseRequest(smtpSendContract, request, {}) @@ -120,25 +121,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const attachmentBuffers: { filename: string; content: Buffer; contentType: string }[] = [] - for (const file of attachments) { - const denied = await assertToolFileAccess(file.key, authResult.userId, requestId, logger) - if (denied) return denied - try { - logger.info(`[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)`) - const buffer = await downloadFileFromStorage(file, requestId, logger) - attachmentBuffers.push({ - filename: file.name, - content: buffer, - contentType: file.type || 'application/octet-stream', - }) - } catch (error) { - logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) - throw new Error( - `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } + const accessResults = await Promise.all( + attachments.map((file) => assertToolFileAccess(file.key, userId, requestId, logger)) + ) + const denied = accessResults.find((r) => r !== null) + if (denied) return denied + + const buffers = await Promise.all( + attachments.map(async (file) => { + try { + logger.info( + `[${requestId}] Downloading attachment: ${file.name} (${file.size} bytes)` + ) + return await downloadFileFromStorage(file, requestId, logger) + } catch (error) { + logger.error(`[${requestId}] Failed to download attachment ${file.name}:`, error) + throw new Error( + `Failed to download attachment "${file.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + }) + ) + + const attachmentBuffers = attachments.map((file, i) => ({ + filename: file.name, + content: buffers[i], + contentType: file.type || 'application/octet-stream', + })) logger.info(`[${requestId}] Processed ${attachmentBuffers.length} attachment(s)`) mailOptions.attachments = attachmentBuffers diff --git a/apps/sim/app/api/tools/ssh/read-file-content/route.ts b/apps/sim/app/api/tools/ssh/read-file-content/route.ts index 8a91bf6edd5..778bbc1f634 100644 --- a/apps/sim/app/api/tools/ssh/read-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/read-file-content/route.ts @@ -73,9 +73,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const content = await new Promise((resolve, reject) => { const chunks: Buffer[] = [] + let totalBytes = 0 const readStream = sftp.createReadStream(filePath) readStream.on('data', (chunk: Buffer) => { + totalBytes += chunk.length + if (totalBytes > maxBytes) { + readStream.destroy() + reject(new Error(`File exceeds maximum allowed size of ${params.maxSize}MB`)) + return + } chunks.push(chunk) }) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 3779a6b2982..44152a4ad52 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -17,6 +17,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { TranscriptSegment } from '@/tools/stt/types' const logger = createLogger('SttProxyAPI') @@ -31,7 +32,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -79,6 +80,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const file = Array.isArray(body.audioFile) ? body.audioFile[0] : body.audioFile logger.info(`[${requestId}] Processing uploaded file: ${file.name}`) + const deniedAudio = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedAudio) return deniedAudio audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name // file.type may be missing if the file came from a block that doesn't preserve it @@ -97,6 +100,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : body.audioFileReference logger.info(`[${requestId}] Processing referenced file: ${file.name}`) + const deniedRef = await assertToolFileAccess(file.key, userId, requestId, logger) + if (deniedRef) return deniedRef audioBuffer = await downloadFileFromStorage(file, requestId, logger) audioFileName = file.name diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 8e63421e320..3e9a1ee8640 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn( `[${requestId}] Unauthorized Supabase storage upload attempt: ${authResult.error}` ) @@ -143,6 +144,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) uploadBody = buffer diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 9c27e23ae1d..c9c51c7003e 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -7,6 +7,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { convertMarkdownToHTML } from '@/tools/telegram/utils' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requireWorkflowId: false, }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Telegram send attempt: ${authResult.error}`) return NextResponse.json( { @@ -88,6 +89,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userFile = userFiles[0] logger.info(`[${requestId}] Uploading document: ${userFile.name}`) + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + const buffer = await downloadFileFromStorage(userFile, requestId, logger) const filesOutput = [ { diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 465cc92603b..209bb01557a 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -18,6 +18,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' export const maxDuration = 300 // 5 minutes for large multi-page PDF processing @@ -428,6 +429,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) bytes = buffer.toString('base64') contentType = userFile.type || 'application/octet-stream' diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index ae1021f0fa9..f5f7969ff29 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -8,6 +8,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' const logger = createLogger('VideoProxyAPI') @@ -21,7 +22,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -100,6 +101,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let jobId: string | undefined let actualDuration: number | undefined + if (body.visualReference) { + const denied = await assertToolFileAccess( + body.visualReference.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied + } + try { if (provider === 'runway') { const result = await generateWithRunway( diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 9b8183d413b..4779dcb86fa 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -15,6 +15,7 @@ import { downloadFileFromStorage, resolveInternalFileUrl, } from '@/lib/uploads/utils/file-utils.server' +import { assertToolFileAccess } from '@/app/api/files/authorization' import { convertUsageMetadata, extractTextContent } from '@/providers/google/utils' export const dynamic = 'force-dynamic' @@ -27,7 +28,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Vision analyze attempt: ${authResult.error}`) return NextResponse.json( { @@ -87,6 +88,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { let base64 = userFile.base64 let bufferLength = 0 if (!base64) { + const denied = await assertToolFileAccess( + userFile.key, + authResult.userId, + requestId, + logger + ) + if (denied) return denied const buffer = await downloadFileFromStorage(userFile, requestId, logger) base64 = buffer.toString('base64') bufferLength = buffer.length diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index 859aef52f54..5b9a1cf9383 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -25,7 +25,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success) { + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized WordPress upload attempt: ${authResult.error}`) return NextResponse.json( { diff --git a/apps/sim/app/api/workflows/[id]/log/route.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts new file mode 100644 index 00000000000..f607ba3d0f0 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Override global db mock with the configurable chain mock +vi.mock('@sim/db', () => dbChainMock) + +const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({ + mockValidateWorkflowAccess: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), +})) + +vi.mock('@/app/api/workflows/middleware', () => ({ + validateWorkflowAccess: mockValidateWorkflowAccess, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +vi.mock('@/lib/logs/execution/logging-session', () => ({ + LoggingSession: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(undefined), + markAsFailed: vi.fn().mockResolvedValue(undefined), + safeCompleteWithError: vi.fn().mockResolvedValue(undefined), + safeComplete: vi.fn().mockResolvedValue(undefined), + })), +})) + +vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({ + buildTraceSpans: vi.fn().mockReturnValue([]), +})) + +import { POST } from './route' + +const makeRequest = (workflowId: string, body: unknown) => + new NextRequest(`http://localhost/api/workflows/${workflowId}/log`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const validResult = { success: true, output: { value: 42 } } + +describe('POST /api/workflows/[id]/log cross-tenant guard', () => { + const OWNER_WORKFLOW_ID = 'wf-owner' + const ATTACKER_WORKFLOW_ID = 'wf-attacker' + const VICTIM_EXECUTION_ID = 'exec-victim-uuid' + + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockValidateWorkflowAccess.mockResolvedValue({ error: null }) + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1') + // Default: no existing log (fresh execution) + dbChainMockFns.limit.mockResolvedValue([]) + }) + + it('returns 404 when executionId belongs to a different workflow', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }]) + + const res = await POST( + makeRequest(ATTACKER_WORKFLOW_ID, { + executionId: VICTIM_EXECUTION_ID, + result: validResult, + }), + { params: Promise.resolve({ id: ATTACKER_WORKFLOW_ID }) } + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('Execution not found') + }) + + it('proceeds when executionId belongs to the same workflow', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }]) + + const res = await POST( + makeRequest(OWNER_WORKFLOW_ID, { + executionId: VICTIM_EXECUTION_ID, + result: validResult, + }), + { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) } + ) + + expect(res.status).not.toBe(404) + expect(res.status).not.toBe(403) + }) + + it('proceeds when executionId has no existing log row (fresh execution)', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + const res = await POST( + makeRequest(OWNER_WORKFLOW_ID, { + executionId: 'brand-new-execution-id', + result: validResult, + }), + { params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) } + ) + + expect(res.status).not.toBe(404) + expect(res.status).not.toBe(403) + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts index 266f53771b7..8d319910a88 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.ts @@ -1,4 +1,7 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { workflowLogContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' @@ -40,6 +43,19 @@ export const POST = withRouteHandler( return createErrorResponse('executionId is required when logging results', 400) } + const [existingLog] = await db + .select({ workflowId: workflowExecutionLogs.workflowId }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) + + if (existingLog && existingLog.workflowId !== id) { + logger.warn( + `[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}` + ) + return createErrorResponse('Execution not found', 404) + } + logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, { executionId, success: result.success, diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts index 3adce4103f3..7cc090c9b97 100644 --- a/apps/sim/app/robots.ts +++ b/apps/sim/app/robots.ts @@ -1,5 +1,5 @@ import type { MetadataRoute } from 'next' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { SITE_URL } from '@/lib/core/utils/urls' const DISALLOWED_PATHS = [ '/api/', @@ -45,8 +45,6 @@ const LINK_PREVIEW_BOTS = [ ] export default function robots(): MetadataRoute.Robots { - const baseUrl = getBaseUrl() - return { rules: [ { userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS }, @@ -56,6 +54,6 @@ export default function robots(): MetadataRoute.Robots { disallow: LINK_PREVIEW_DISALLOWED_PATHS, }, ], - sitemap: [`${baseUrl}/sitemap.xml`, `${baseUrl}/blog/sitemap-images.xml`], + sitemap: [`${SITE_URL}/sitemap.xml`, `${SITE_URL}/blog/sitemap-images.xml`], } } diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 7b28646fa05..8107f3010c0 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -1,12 +1,12 @@ import type { MetadataRoute } from 'next' import { COURSES } from '@/lib/academy/content' import { getAllPostMeta } from '@/lib/blog/registry' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { SITE_URL } from '@/lib/core/utils/urls' import integrations from '@/app/(landing)/integrations/data/integrations.json' import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' export default async function sitemap(): Promise { - const baseUrl = getBaseUrl() + const baseUrl = SITE_URL const posts = await getAllPostMeta() const latestPostDate = @@ -46,6 +46,9 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/partners`, }, + { + url: `${baseUrl}/contact`, + }, { url: `${baseUrl}/terms`, lastModified: new Date('2024-10-14'), diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 64c364677d7..fc3442c124f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -98,7 +98,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ - - -

{displayName}

-
- + + + + + )} + {showGapAfter && (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index da0a9dc3cd2..c770e2b26bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -21,7 +21,11 @@ import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { captureEvent } from '@/lib/posthog/client' import { workflowBorderColor } from '@/lib/workspaces/colors' -import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' +import { + formatDateShort, + type LogStatus, + STATUS_CONFIG, +} from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' @@ -43,28 +47,6 @@ const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'Custom range', label: 'Custom range' }, ] as const -/** - * Formats a date string (YYYY-MM-DD) for display. - */ -function formatDateShort(dateStr: string): string { - const date = new Date(dateStr) - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - return `${months[date.getMonth()]} ${date.getDate()}` -} - type ViewMode = 'logs' | 'dashboard' interface LogsToolbarProps { @@ -794,11 +776,13 @@ export const LogsToolbar = memo(function LogsToolbar({ } size='sm' align='end' - className='h-[32px] w-[120px] rounded-md' + className='h-[32px] w-[160px] rounded-md' + maxHeight={320} /> { if (!isOpen) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 7aa4ad2322e..7ddf9eec8f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -91,6 +91,7 @@ import { DELETED_WORKFLOW_LABEL, extractRetryInput, formatDate, + formatDateShort, getDisplayStatus, type LogStatus, parseDuration, @@ -205,25 +206,6 @@ function SpinningRefreshCw(props: React.SVGProps) { return } -function formatDateShort(dateStr: string): string { - const date = new Date(dateStr) - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ] - return `${months[date.getMonth()]} ${date.getDate()}` -} - /** * Logs page component displaying workflow execution history. * Supports filtering, search, live updates, and detailed log inspection. @@ -866,7 +848,7 @@ export default function Logs() { tags.push({ label: timeRange === 'Custom range' && startDate && endDate - ? `${startDate} – ${endDate}` + ? `${formatDateShort(startDate)} – ${formatDateShort(endDate)}` : timeRange, onRemove: () => { clearDateRange() @@ -1519,10 +1501,12 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr } size='sm' className='h-[32px] w-full rounded-md' + maxHeight={320} /> { if (!isOpen) { diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 8fa8f4624a6..a8a847f4d86 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -179,6 +179,31 @@ export function formatLatency(ms: number): string { return formatDuration(ms, { precision: 2 }) ?? '—' } +export function formatDateShort(dateStr: string): string { + const hasTime = dateStr.includes('T') + const [datePart, timePart] = dateStr.split('T') + const [, month, day] = datePart.split('-').map(Number) + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ] + const dateLabel = `${months[month - 1]} ${day}` + if (hasTime && timePart) { + return `${dateLabel} ${timePart.slice(0, 5)}` + } + return dateLabel +} + export const formatDate = (dateString: string) => { const date = new Date(dateString) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 4462bcda2c5..1fa4255a6f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -416,6 +416,18 @@ function createToolIcon( * - Allows drag-and-drop reordering of selected tools * - Supports tool usage control (auto/force/none) for compatible LLM providers */ + +function IconComponent({ + icon: Icon, + className, +}: { + icon?: React.ComponentType<{ className?: string }> + className?: string +}) { + if (!Icon) return null + return +} + export const ToolInput = memo(function ToolInput({ blockId, subBlockId, @@ -1071,17 +1083,6 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const IconComponent = ({ - icon: Icon, - className, - }: { - icon?: React.ComponentType<{ className?: string }> - className?: string - }) => { - if (!Icon) return null - return - } - const evaluateParameterCondition = (param: ToolParameterConfig, tool: StoredTool): boolean => { if (!('uiComponent' in param) || !param.uiComponent?.condition) return true const currentValues: Record = { operation: tool.operation, ...tool.params } diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts index 30e5245d141..585edb6a074 100644 --- a/apps/sim/blocks/blocks/cloudwatch.ts +++ b/apps/sim/blocks/blocks/cloudwatch.ts @@ -8,8 +8,10 @@ import type { CloudWatchGetLogEventsResponse, CloudWatchGetMetricStatisticsResponse, CloudWatchListMetricsResponse, + CloudWatchMuteAlarmResponse, CloudWatchPutMetricDataResponse, CloudWatchQueryLogsResponse, + CloudWatchUnmuteAlarmResponse, } from '@/tools/cloudwatch/types' export const CloudWatchBlock: BlockConfig< @@ -21,6 +23,8 @@ export const CloudWatchBlock: BlockConfig< | CloudWatchListMetricsResponse | CloudWatchGetMetricStatisticsResponse | CloudWatchPutMetricDataResponse + | CloudWatchMuteAlarmResponse + | CloudWatchUnmuteAlarmResponse > = { type: 'cloudwatch', name: 'CloudWatch', @@ -47,6 +51,8 @@ export const CloudWatchBlock: BlockConfig< { label: 'Get Metric Statistics', id: 'get_metric_statistics' }, { label: 'Publish Metric', id: 'put_metric_data' }, { label: 'Describe Alarms', id: 'describe_alarms' }, + { label: 'Mute Alarm', id: 'mute_alarm' }, + { label: 'Unmute Alarm', id: 'unmute_alarm' }, ], value: () => 'query_logs', }, @@ -360,6 +366,14 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, value: () => '', condition: { field: 'operation', value: 'describe_alarms' }, }, + { + id: 'alarmNames', + title: 'Alarm Names', + type: 'short-input', + placeholder: 'my-alarm-1, my-alarm-2', + condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + }, { id: 'limit', title: 'Limit', @@ -389,6 +403,8 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, 'cloudwatch_get_metric_statistics', 'cloudwatch_put_metric_data', 'cloudwatch_describe_alarms', + 'cloudwatch_mute_alarm', + 'cloudwatch_unmute_alarm', ], config: { tool: (params) => { @@ -409,6 +425,10 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, return 'cloudwatch_put_metric_data' case 'describe_alarms': return 'cloudwatch_describe_alarms' + case 'mute_alarm': + return 'cloudwatch_mute_alarm' + case 'unmute_alarm': + return 'cloudwatch_unmute_alarm' default: throw new Error(`Invalid CloudWatch operation: ${params.operation}`) } @@ -613,6 +633,33 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(parsedLimit !== undefined && { limit: parsedLimit }), } + case 'mute_alarm': + case 'unmute_alarm': { + const alarmNames = rest.alarmNames + if (!alarmNames) { + throw new Error('Alarm names are required') + } + + const names = + typeof alarmNames === 'string' + ? alarmNames + .split(',') + .map((n: string) => n.trim()) + .filter(Boolean) + : alarmNames + + if (!Array.isArray(names) || names.length === 0) { + throw new Error('At least one alarm name is required') + } + + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + alarmNames: names, + } + } + default: throw new Error(`Invalid CloudWatch operation: ${operation}`) } @@ -653,6 +700,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)', }, alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' }, + alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' }, limit: { type: 'number', description: 'Maximum number of results' }, }, outputs: { @@ -696,9 +744,13 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, type: 'array', description: 'CloudWatch alarms with state and configuration', }, + alarmNames: { + type: 'array', + description: 'Names of the alarms that were muted or unmuted', + }, success: { type: 'boolean', - description: 'Whether the published metric was successful', + description: 'Whether the operation completed successfully', }, namespace: { type: 'string', diff --git a/apps/sim/components/emcn/components/date-picker/date-picker.tsx b/apps/sim/components/emcn/components/date-picker/date-picker.tsx index ca6ac6c0de3..4aa1c3dc074 100644 --- a/apps/sim/components/emcn/components/date-picker/date-picker.tsx +++ b/apps/sim/components/emcn/components/date-picker/date-picker.tsx @@ -33,6 +33,7 @@ import { PopoverAnchor, PopoverContent, } from '@/components/emcn/components/popover/popover' +import { TimePicker } from '@/components/emcn/components/time-picker/time-picker' import { cn } from '@/lib/core/utils/cn' /** @@ -96,22 +97,26 @@ interface DatePickerSingleProps extends DatePickerBaseProps { onCancel?: never /** Not used in single mode */ onClear?: never + /** Not used in single mode */ + showTime?: never } /** Props for range date mode */ interface DatePickerRangeProps extends DatePickerBaseProps { /** Selection mode */ mode: 'range' - /** Start date for range mode (YYYY-MM-DD string or Date) */ + /** Start date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */ startDate?: string | Date - /** End date for range mode (YYYY-MM-DD string or Date) */ + /** End date for range mode (YYYY-MM-DD or YYYY-MM-DDTHH:mm string or Date) */ endDate?: string | Date - /** Callback when date range is applied */ + /** Callback when date range is applied — returns YYYY-MM-DD or YYYY-MM-DDTHH:mm depending on showTime */ onRangeChange?: (startDate: string, endDate: string) => void /** Callback when range selection is cancelled */ onCancel?: () => void /** Callback when range is cleared */ onClear?: () => void + /** Whether to show time inputs for precise range selection */ + showTime?: boolean /** Not used in range mode */ value?: never /** Not used in range mode */ @@ -121,8 +126,22 @@ interface DatePickerRangeProps extends DatePickerBaseProps { export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps /** - * Month names for calendar display. + * Flattened props type for safe destructuring. + * The discriminated union prevents direct destructuring of mode-specific props, + * so we cast to this merged shape after the forwardRef boundary. */ +type FlatDatePickerProps = DatePickerBaseProps & { + mode?: 'single' | 'range' + value?: string | Date + onChange?: (value: string) => void + startDate?: string | Date + endDate?: string | Date + onRangeChange?: (startDate: string, endDate: string) => void + onCancel?: () => void + onClear?: () => void + showTime?: boolean +} + const MONTHS = [ 'January', 'February', @@ -138,28 +157,8 @@ const MONTHS = [ 'December', ] -/** - * Day abbreviations for calendar header. - */ const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] -/** - * Gets the number of days in a given month. - */ -function getDaysInMonth(year: number, month: number): number { - return new Date(year, month + 1, 0).getDate() -} - -/** - * Gets the day of the week (0-6) for the first day of the month. - */ -function getFirstDayOfMonth(year: number, month: number): number { - return new Date(year, month, 1).getDay() -} - -/** - * Short month names for display. - */ const MONTHS_SHORT = [ 'Jan', 'Feb', @@ -175,9 +174,14 @@ const MONTHS_SHORT = [ 'Dec', ] -/** - * Formats a date for display in the trigger button. - */ +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate() +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay() +} + function formatDateForDisplay(date: Date | null): string { if (!date) return '' return date.toLocaleDateString('en-US', { @@ -187,9 +191,6 @@ function formatDateForDisplay(date: Date | null): string { }) } -/** - * Formats a date range for display. - */ function formatDateRangeForDisplay(start: Date | null, end: Date | null): string { if (!start && !end) return '' if (start && !end) return formatDateForDisplay(start) @@ -205,9 +206,6 @@ function formatDateRangeForDisplay(start: Date | null, end: Date | null): string return '' } -/** - * Checks if a date is between two dates (inclusive). - */ function isDateInRange(date: Date, start: Date | null, end: Date | null): boolean { if (!start || !end) return false const time = date.getTime() @@ -216,9 +214,6 @@ function isDateInRange(date: Date, start: Date | null, end: Date | null): boolea return time >= startTime && time <= endTime } -/** - * Checks if two dates are the same day. - */ function isSameDay(date1: Date, date2: Date): boolean { return ( date1.getFullYear() === date2.getFullYear() && @@ -227,9 +222,6 @@ function isSameDay(date1: Date, date2: Date): boolean { ) } -/** - * Formats a date as YYYY-MM-DD string. - */ function formatDateAsString(year: number, month: number, day: number): string { const m = (month + 1).toString().padStart(2, '0') const d = day.toString().padStart(2, '0') @@ -238,7 +230,7 @@ function formatDateAsString(year: number, month: number, day: number): string { /** * Parses a string or Date value into a Date object. - * Handles various date formats including YYYY-MM-DD and ISO strings. + * YYYY-MM-DD strings are parsed as local time to avoid UTC offset shifts. */ function parseDate(value: string | Date | undefined): Date | null { if (!value) return null @@ -267,9 +259,6 @@ function parseDate(value: string | Date | undefined): Date | null { } } -/** - * Calendar component for rendering a single month. - */ interface CalendarMonthProps { viewMonth: number viewYear: number @@ -291,7 +280,7 @@ function CalendarMonth({ selectedDate, rangeStart, rangeEnd, - hoverDate, + hoverDate: _hoverDate, isRangeMode, onSelectDate, onHoverDate, @@ -363,17 +352,13 @@ function CalendarMonth({ const isInRange = React.useCallback( (day: number) => { - if (!isRangeMode) return false + if (!isRangeMode || !rangeStart || !rangeEnd) return false const date = new Date(viewYear, viewMonth, day) - // Only show range highlight when both start and end are selected - if (rangeStart && rangeEnd) { - return ( - isDateInRange(date, rangeStart, rangeEnd) && - !isSameDay(date, rangeStart) && - !isSameDay(date, rangeEnd) - ) - } - return false + return ( + isDateInRange(date, rangeStart, rangeEnd) && + !isSameDay(date, rangeStart) && + !isSameDay(date, rangeEnd) + ) }, [isRangeMode, rangeStart, rangeEnd, viewMonth, viewYear] ) @@ -485,80 +470,99 @@ const DatePicker = React.forwardRef((props, ref className, variant, size, - placeholder = props.mode === 'range' ? 'Select date range' : 'Select date', + placeholder: placeholderProp, disabled, showTrigger = true, open: controlledOpen, onOpenChange, inline = false, - mode: _mode, - ...rest - } = props - - const { - value: _value, - onChange: _onChange, - startDate: _startDate, - endDate: _endDate, - onRangeChange: _onRangeChange, - onCancel: _onCancel, - onClear: _onClear, + mode, + value, + onChange, + startDate, + endDate, + onRangeChange, + onCancel, + onClear, + showTime = false, ...htmlProps - } = rest as any + } = props as FlatDatePickerProps - const isRangeMode = props.mode === 'range' + const isRangeMode = mode === 'range' + const placeholder = placeholderProp ?? (isRangeMode ? 'Select date range' : 'Select date') const isControlled = controlledOpen !== undefined const [internalOpen, setInternalOpen] = React.useState(false) const open = isControlled ? controlledOpen : internalOpen const setOpen = React.useCallback( - (value: boolean) => { - if (!isControlled) { - setInternalOpen(value) - } - onOpenChange?.(value) + (next: boolean) => { + if (!isControlled) setInternalOpen(next) + onOpenChange?.(next) }, [isControlled, onOpenChange] ) - const selectedDate = !isRangeMode ? parseDate(props.value) : null + const selectedDate = !isRangeMode ? parseDate(value) : null - const initialStart = isRangeMode ? parseDate(props.startDate) : null - const initialEnd = isRangeMode ? parseDate(props.endDate) : null - const [rangeStart, setRangeStart] = React.useState(initialStart) - const [rangeEnd, setRangeEnd] = React.useState(initialEnd) + const [rangeStart, setRangeStart] = React.useState(() => + isRangeMode ? parseDate(startDate) : null + ) + const [rangeEnd, setRangeEnd] = React.useState(() => + isRangeMode ? parseDate(endDate) : null + ) const [hoverDate, setHoverDate] = React.useState(null) const [selectingEnd, setSelectingEnd] = React.useState(false) + const [startTime, setStartTime] = React.useState('00:00') + const [endTime, setEndTime] = React.useState('23:59') const [viewMonth, setViewMonth] = React.useState(() => { - const d = selectedDate || initialStart || new Date() + const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date() return d.getMonth() }) const [viewYear, setViewYear] = React.useState(() => { - const d = selectedDate || initialStart || new Date() + const d = selectedDate ?? (isRangeMode ? parseDate(startDate) : null) ?? new Date() return d.getFullYear() }) const rightViewMonth = viewMonth === 11 ? 0 : viewMonth + 1 const rightViewYear = viewMonth === 11 ? viewYear + 1 : viewYear + // Sync range state when the popover opens with the current prop values. + // Deps are the raw string/Date props — NOT derived Date objects — to avoid + // an infinite re-render loop: Object.is(new Date(), new Date()) === false, + // so derived Date objects in deps cause the effect to fire every render. React.useEffect(() => { - if (open && isRangeMode) { - setRangeStart(initialStart) - setRangeEnd(initialEnd) - setSelectingEnd(false) - if (initialStart) { - setViewMonth(initialStart.getMonth()) - setViewYear(initialStart.getFullYear()) - } else { - const now = new Date() - setViewMonth(now.getMonth()) - setViewYear(now.getFullYear()) - } + if (!open || !isRangeMode) return + + const start = parseDate(startDate) + const end = parseDate(endDate) + setRangeStart(start) + setRangeEnd(end) + setSelectingEnd(false) + + if (showTime) { + setStartTime( + typeof startDate === 'string' && startDate.includes('T') ? startDate.slice(11, 16) : '00:00' + ) + setEndTime( + typeof endDate === 'string' && endDate.includes('T') ? endDate.slice(11, 16) : '23:59' + ) } - }, [open, isRangeMode, initialStart, initialEnd]) + if (start) { + setViewMonth(start.getMonth()) + setViewYear(start.getFullYear()) + } else { + const now = new Date() + setViewMonth(now.getMonth()) + setViewYear(now.getFullYear()) + } + }, [open, isRangeMode, startDate, endDate, showTime]) + + // Sync the calendar view when the external single-date value changes. + // This is a render-phase state update (derived state pattern): safe because + // it only triggers when singleValueKey — a primitive timestamp — actually changes. const singleValueKey = !isRangeMode && selectedDate ? selectedDate.getTime() : undefined const [prevSingleValueKey, setPrevSingleValueKey] = React.useState(singleValueKey) if (singleValueKey !== prevSingleValueKey) { @@ -569,26 +573,19 @@ const DatePicker = React.forwardRef((props, ref } } - /** - * Handles selection of a specific day in single mode. - */ const handleSelectDateSingle = React.useCallback( (day: number) => { - if (!isRangeMode && props.onChange) { - props.onChange(formatDateAsString(viewYear, viewMonth, day)) + if (!isRangeMode) { + onChange?.(formatDateAsString(viewYear, viewMonth, day)) setOpen(false) } }, - [isRangeMode, viewYear, viewMonth, props.onChange, setOpen] + [isRangeMode, onChange, viewYear, viewMonth, setOpen] ) - /** - * Handles selection of a day in range mode. - */ const handleSelectDateRange = React.useCallback( (year: number, month: number, day: number) => { const date = new Date(year, month, day) - if (!selectingEnd || !rangeStart) { setRangeStart(date) setRangeEnd(null) @@ -606,94 +603,72 @@ const DatePicker = React.forwardRef((props, ref [selectingEnd, rangeStart] ) - /** - * Handles hover for range preview. - */ const handleHoverDate = React.useCallback((year: number, month: number, day: number | null) => { - if (day === null) { - setHoverDate(null) - } else { - setHoverDate(new Date(year, month, day)) - } + setHoverDate(day === null ? null : new Date(year, month, day)) }, []) - /** - * Navigates to the previous month. - */ const goToPrevMonth = React.useCallback(() => { if (viewMonth === 0) { setViewMonth(11) - setViewYear((prev) => prev - 1) + setViewYear((y) => y - 1) } else { - setViewMonth((prev) => prev - 1) + setViewMonth((m) => m - 1) } }, [viewMonth]) - /** - * Navigates to the next month. - */ const goToNextMonth = React.useCallback(() => { if (viewMonth === 11) { setViewMonth(0) - setViewYear((prev) => prev + 1) + setViewYear((y) => y + 1) } else { - setViewMonth((prev) => prev + 1) + setViewMonth((m) => m + 1) } }, [viewMonth]) - /** - * Selects today's date (single mode only). - */ const handleSelectToday = React.useCallback(() => { - if (!isRangeMode && props.onChange) { + if (!isRangeMode) { const now = new Date() setViewMonth(now.getMonth()) setViewYear(now.getFullYear()) - props.onChange(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) + onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate())) setOpen(false) } - }, [isRangeMode, props.onChange, setOpen]) + }, [isRangeMode, onChange, setOpen]) - /** - * Applies the selected range (range mode only). - */ const handleApplyRange = React.useCallback(() => { - if (isRangeMode && props.onRangeChange && rangeStart) { - const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart - const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : rangeEnd || rangeStart - props.onRangeChange( - formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()), - formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate()) - ) - setOpen(false) + if (!isRangeMode || !onRangeChange || !rangeStart) return + + const start = rangeEnd && rangeEnd < rangeStart ? rangeEnd : rangeStart + const end = rangeEnd && rangeEnd < rangeStart ? rangeStart : (rangeEnd ?? rangeStart) + const startStr = formatDateAsString(start.getFullYear(), start.getMonth(), start.getDate()) + const endStr = formatDateAsString(end.getFullYear(), end.getMonth(), end.getDate()) + + let effectiveStartTime = startTime + let effectiveEndTime = endTime + if (showTime && startStr === endStr && startTime > endTime) { + effectiveStartTime = endTime + effectiveEndTime = startTime } - }, [isRangeMode, props.onRangeChange, rangeStart, rangeEnd, setOpen]) - /** - * Cancels range selection. - */ + onRangeChange( + showTime ? `${startStr}T${effectiveStartTime}` : startStr, + showTime ? `${endStr}T${effectiveEndTime}:59` : endStr + ) + setOpen(false) + }, [isRangeMode, onRangeChange, rangeStart, rangeEnd, showTime, startTime, endTime, setOpen]) + const handleCancelRange = React.useCallback(() => { - if (isRangeMode && props.onCancel) { - props.onCancel() - } + if (isRangeMode) onCancel?.() setOpen(false) - }, [isRangeMode, props.onCancel, setOpen]) + }, [isRangeMode, onCancel, setOpen]) - /** - * Clears the selected range. - */ const handleClearRange = React.useCallback(() => { setRangeStart(null) setRangeEnd(null) setSelectingEnd(false) - if (isRangeMode && props.onClear) { - props.onClear() - } - }, [isRangeMode, props.onClear]) + if (isRangeMode) onClear?.() + }, [isRangeMode, onClear]) - /** - * Handles keyboard events on the trigger. - */ const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { if (!disabled && (e.key === 'Enter' || e.key === ' ')) { @@ -704,17 +679,12 @@ const DatePicker = React.forwardRef((props, ref [disabled, open, setOpen] ) - /** - * Handles click on the trigger. - */ const handleTriggerClick = React.useCallback(() => { - if (!disabled) { - setOpen(!open) - } + if (!disabled) setOpen(!open) }, [disabled, open, setOpen]) const displayValue = isRangeMode - ? formatDateRangeForDisplay(initialStart, initialEnd) + ? formatDateRangeForDisplay(parseDate(startDate), parseDate(endDate)) : formatDateForDisplay(selectedDate) const calendarContent = isRangeMode ? ( @@ -754,6 +724,21 @@ const DatePicker = React.forwardRef((props, ref />
+ {/* Time inputs */} + {showTime && ( +
+
+ Start + +
+
+
+ End + +
+
+ )} + {/* Actions */}