diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index 293b7aa97d..0649ddd27a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -3,14 +3,20 @@ import { useEffect, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { - Button, - Modal, - ModalContent, - ModalDescription, - ModalFooter, - ModalHeader, - ModalTitle, -} from '@/components/emcn' + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getSubscriptionStatus } from '@/lib/subscription/helpers' @@ -137,8 +143,9 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub let subscriptionId: string | undefined if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { + const orgSubscription = useOrganizationStore.getState().subscriptionData referenceId = activeOrgId - subscriptionId = subData?.data?.id + subscriptionId = orgSubscription?.id } else { // For personal subscriptions, use user ID and let better-auth find the subscription referenceId = session.user.id @@ -152,12 +159,24 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub if (subscriptionId) { restoreParams.subscriptionId = subscriptionId } + } + + logger.info('Restoring subscription', { referenceId, subscriptionId }) + + // Build restore params - only include subscriptionId if we have one (team/enterprise) + const restoreParams: any = { referenceId } + if (subscriptionId) { + restoreParams.subscriptionId = subscriptionId + } + } const result = await betterAuthSubscription.restore(restoreParams) logger.info('Subscription restored successfully', result) } + // Refresh state and close + await refresh() // Invalidate queries to refresh data await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() }) if (activeOrgId) { @@ -225,8 +244,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub onClick={() => setIsDialogOpen(true)} disabled={isLoading} className={cn( - 'h-8 rounded-[8px] font-medium text-xs', - error && 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500' + 'h-8 rounded-[8px] font-medium text-xs transition-all duration-200', + error + ? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500' + : isCancelAtPeriodEnd + ? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500' + : 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500' )} > {error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'} @@ -280,13 +303,13 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const subscriptionStatus = currentSubscriptionStatus if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) { return ( - + ) } return ( diff --git a/apps/sim/lib/uploads/core/setup.server.ts b/apps/sim/lib/uploads/core/setup.server.ts index 36b1a12206..5210c422aa 100644 --- a/apps/sim/lib/uploads/core/setup.server.ts +++ b/apps/sim/lib/uploads/core/setup.server.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'fs' -import { mkdir } from 'fs/promises' -import path, { join } from 'path' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { getStorageProvider, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' @@ -24,7 +21,15 @@ export async function ensureUploadsDirectory() { return true } + if (typeof window !== 'undefined' || !UPLOAD_DIR_SERVER) { + // Skip on client side + return true + } + try { + const { existsSync } = require('fs') + const { mkdir } = require('fs/promises') + if (!existsSync(UPLOAD_DIR_SERVER)) { await mkdir(UPLOAD_DIR_SERVER, { recursive: true }) } else { diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index 2ae29b8a69..60adb6f583 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -1,7 +1,254 @@ import { USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config' import type { StorageConfig } from '@/lib/uploads/shared/types' -export type { StorageConfig } from '@/lib/uploads/shared/types' +import { createLogger } from '@/lib/logs/console/logger' +import { USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/core/setup' +import type { CustomBlobConfig } from '@/lib/uploads/providers/blob/blob-client' +import type { CustomS3Config } from '@/lib/uploads/providers/s3/s3-client' + +const logger = createLogger('StorageClient') + +// Client-safe type definitions +export type FileInfo = { + path: string + key: string + name: string + size: number + type: string +} + +export type CustomStorageConfig = { + // S3 config + bucket?: string + region?: string + // Blob config + containerName?: string + accountName?: string + accountKey?: string + connectionString?: string +} + +/** + * Upload a file to the configured storage provider + * @param file Buffer containing file data + * @param fileName Original file name + * @param contentType MIME type of the file + * @param size File size in bytes (optional, will use buffer length if not provided) + * @returns Object with file information + */ +export async function uploadFile( + file: Buffer, + fileName: string, + contentType: string, + size?: number +): Promise + +/** + * Upload a file to the configured storage provider with custom configuration + * @param file Buffer containing file data + * @param fileName Original file name + * @param contentType MIME type of the file + * @param customConfig Custom storage configuration + * @param size File size in bytes (optional, will use buffer length if not provided) + * @returns Object with file information + */ +export async function uploadFile( + file: Buffer, + fileName: string, + contentType: string, + customConfig: CustomStorageConfig, + size?: number +): Promise + +export async function uploadFile( + file: Buffer, + fileName: string, + contentType: string, + configOrSize?: CustomStorageConfig | number, + size?: number +): Promise { + if (USE_BLOB_STORAGE) { + logger.info(`Uploading file to Azure Blob Storage: ${fileName}`) + const { uploadToBlob } = await import('@/lib/uploads/providers/blob/blob-client') + if (typeof configOrSize === 'object') { + const blobConfig: CustomBlobConfig = { + containerName: configOrSize.containerName!, + accountName: configOrSize.accountName!, + accountKey: configOrSize.accountKey, + connectionString: configOrSize.connectionString, + } + return uploadToBlob(file, fileName, contentType, blobConfig, size) + } + return uploadToBlob(file, fileName, contentType, configOrSize) + } + + if (USE_S3_STORAGE) { + logger.info(`Uploading file to S3: ${fileName}`) + const { uploadToS3 } = await import('@/lib/uploads/providers/s3/s3-client') + if (typeof configOrSize === 'object') { + const s3Config: CustomS3Config = { + bucket: configOrSize.bucket!, + region: configOrSize.region!, + } + return uploadToS3(file, fileName, contentType, s3Config, size) + } + return uploadToS3(file, fileName, contentType, configOrSize) + } + + if (typeof window !== 'undefined') { + throw new Error('Local file upload is only supported on the server') + } + + logger.info(`Uploading file to local storage: ${fileName}`) + const { writeFile } = require('fs/promises') + const { join } = require('path') + const { v4: uuidv4 } = require('uuid') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') + + const safeFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_').replace(/\.\./g, '') + const uniqueKey = `${uuidv4()}-${safeFileName}` + const filePath = join(UPLOAD_DIR_SERVER, uniqueKey) + + try { + await writeFile(filePath, file) + } catch (error) { + logger.error(`Failed to write file to local storage: ${fileName}`, error) + throw new Error( + `Failed to write file to local storage: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + + const fileSize = typeof configOrSize === 'number' ? configOrSize : size || file.length + + return { + path: `/api/files/serve/${uniqueKey}`, + key: uniqueKey, + name: fileName, + size: fileSize, + type: contentType, + } +} + +/** + * Download a file from the configured storage provider + * @param key File key/name + * @returns File buffer + */ +export async function downloadFile(key: string): Promise + +/** + * Download a file from the configured storage provider with custom configuration + * @param key File key/name + * @param customConfig Custom storage configuration + * @returns File buffer + */ +export async function downloadFile(key: string, customConfig: CustomStorageConfig): Promise + +export async function downloadFile( + key: string, + customConfig?: CustomStorageConfig +): Promise { + if (USE_BLOB_STORAGE) { + logger.info(`Downloading file from Azure Blob Storage: ${key}`) + const { downloadFromBlob } = await import('@/lib/uploads/providers/blob/blob-client') + if (customConfig) { + const blobConfig: CustomBlobConfig = { + containerName: customConfig.containerName!, + accountName: customConfig.accountName!, + accountKey: customConfig.accountKey, + connectionString: customConfig.connectionString, + } + return downloadFromBlob(key, blobConfig) + } + return downloadFromBlob(key) + } + + if (USE_S3_STORAGE) { + logger.info(`Downloading file from S3: ${key}`) + const { downloadFromS3 } = await import('@/lib/uploads/providers/s3/s3-client') + if (customConfig) { + const s3Config: CustomS3Config = { + bucket: customConfig.bucket!, + region: customConfig.region!, + } + return downloadFromS3(key, s3Config) + } + return downloadFromS3(key) + } + + if (typeof window !== 'undefined') { + throw new Error('Local file download is only supported on the server') + } + + logger.info(`Downloading file from local storage: ${key}`) + const { readFile } = require('fs/promises') + const { join, resolve, sep } = require('path') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') + + const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '') + const filePath = join(UPLOAD_DIR_SERVER, safeKey) + + const resolvedPath = resolve(filePath) + const allowedDir = resolve(UPLOAD_DIR_SERVER) + if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) { + throw new Error('Invalid file path') + } + + try { + return await readFile(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`File not found: ${key}`) + } + throw error + } +} + +/** + * Delete a file from the configured storage provider + * @param key File key/name + */ +export async function deleteFile(key: string): Promise { + if (USE_BLOB_STORAGE) { + logger.info(`Deleting file from Azure Blob Storage: ${key}`) + const { deleteFromBlob } = await import('@/lib/uploads/providers/blob/blob-client') + return deleteFromBlob(key) + } + + if (USE_S3_STORAGE) { + logger.info(`Deleting file from S3: ${key}`) + const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/s3-client') + return deleteFromS3(key) + } + + if (typeof window !== 'undefined') { + throw new Error('Local file deletion is only supported on the server') + } + + logger.info(`Deleting file from local storage: ${key}`) + const { unlink } = require('fs/promises') + const { join, resolve, sep } = require('path') + const { UPLOAD_DIR_SERVER } = require('@/lib/uploads/core/setup.server') + + const safeKey = key.replace(/\.\./g, '').replace(/[/\\]/g, '') + const filePath = join(UPLOAD_DIR_SERVER, safeKey) + + const resolvedPath = resolve(filePath) + const allowedDir = resolve(UPLOAD_DIR_SERVER) + if (!resolvedPath.startsWith(allowedDir + sep) && resolvedPath !== allowedDir) { + throw new Error('Invalid file path') + } + + try { + await unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.warn(`File not found during deletion: ${key}`) + return + } + throw error + } +} /** * Get the current storage provider name diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 628ee264fd..d5630d3822 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -4,6 +4,30 @@ import { isDev, isHosted } from './lib/environment' import { getMainCSPPolicy, getWorkflowExecutionCSPPolicy } from './lib/security/csp' const nextConfig: NextConfig = { + webpack: (config, { isServer }) => { + if (!isServer) { + // Don't resolve server-only modules on the client + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + 'fs/promises': false, + path: false, + dns: false, + util: false, + net: false, + tls: false, + crypto: false, + stream: false, + perf_hooks: false, + os: false, + http: false, + https: false, + zlib: false, + child_process: false, + } + } + return config + }, devIndicators: false, images: { remotePatterns: [ @@ -75,7 +99,7 @@ const nextConfig: NextConfig = { turbopack: { resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'], }, - serverExternalPackages: ['unpdf', 'ffmpeg-static', 'fluent-ffmpeg'], + serverExternalPackages: ['unpdf', 'ffmpeg-static', 'fluent-ffmpeg', '@azure/storage-blob', '@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'], experimental: { optimizeCss: true, turbopackSourceMaps: false, diff --git a/apps/sim/package.json b/apps/sim/package.json index 97d4733aff..d9895b8e54 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -8,7 +8,7 @@ "node": ">=20.0.0" }, "scripts": { - "dev": "next dev --turbo --port 3000", + "dev": "next dev --port 3000", "dev:classic": "next dev", "dev:sockets": "bun run socket-server/index.ts", "dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", @@ -39,6 +39,8 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-node": "^0.200.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "@opentelemetry/sdk-trace-node": "2.2.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "1.1.10", @@ -59,11 +61,13 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-visually-hidden": "1.2.3", "@react-email/components": "^0.0.34", "@trigger.dev/sdk": "4.0.4", "@types/three": "0.177.0", "better-auth": "1.3.12", "browser-image-compression": "^2.0.2", + "chalk": "^5.3.0", "cheerio": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -86,10 +90,10 @@ "js-yaml": "4.1.0", "jszip": "3.10.1", "jwt-decode": "^4.0.0", + "lodash": "4.17.21", "lucide-react": "^0.479.0", "mammoth": "^1.9.0", "mysql2": "3.14.3", - "nanoid": "^3.3.7", "next": "^15.4.1", "next-mdx-remote": "^5.0.0", "next-runtime-env": "3.3.0", @@ -112,6 +116,7 @@ "rehype-slug": "^6.0.0", "remark-gfm": "4.0.1", "resend": "^4.1.2", + "server-only": "0.0.1", "sharp": "0.34.3", "socket.io": "^4.8.1", "stripe": "18.5.0", @@ -121,9 +126,11 @@ "unpdf": "1.4.0", "uuid": "^11.1.0", "xlsx": "0.18.5", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^4.5.5" }, "devDependencies": { + "@playwright/test": "1.56.1", "@testing-library/jest-dom": "^6.6.3", "@trigger.dev/build": "4.0.4", "@types/html-to-text": "9.0.4",