diff --git a/apps/app/buildspec.yml b/apps/app/buildspec.yml index e7da25944..512c106fe 100644 --- a/apps/app/buildspec.yml +++ b/apps/app/buildspec.yml @@ -42,9 +42,13 @@ phases: - echo "Type checking..." - cd apps/$APP_NAME && bun run typecheck && cd ../../ + # Clear Next.js cache to prevent stale server actions + - echo "Clearing Next.js cache to prevent stale builds..." + - cd apps/$APP_NAME + - rm -rf .next/cache/ + # Build Next.js app - echo "Building Next.js application..." - - cd apps/$APP_NAME - NODE_TLS_REJECT_UNAUTHORIZED=0 bun run build # Upload Next.js chunks and CSS to S3 for CDN performance @@ -56,6 +60,24 @@ phases: --cache-control "public, max-age=31536000, immutable" \ --exclude "*.map" echo "โœ… Uploaded Next.js static assets to S3" + + # Verify upload completed successfully + echo "๐Ÿ” Verifying S3 upload completed..." + CHUNK_COUNT=$(find .next/static -name "*.js" | wc -l) + S3_COUNT=$(aws s3 ls s3://$STATIC_ASSETS_BUCKET/app/_next/static/ --recursive | grep "\.js$" | wc -l) + echo "Local chunks: $CHUNK_COUNT, S3 chunks: $S3_COUNT" + + if [ "$S3_COUNT" -ge "$CHUNK_COUNT" ]; then + echo "โœ… S3 upload verification successful" + else + echo "โš ๏ธ S3 upload may be incomplete, but continuing..." + fi + + # Invalidate CloudFront cache for new chunks + echo "๐Ÿ”„ Invalidating CloudFront cache for new deployment..." + aws cloudfront create-invalidation \ + --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \ + --paths "/app/_next/static/*" || echo "โš ๏ธ CloudFront invalidation failed (non-critical)" else echo "โš ๏ธ No .next/static directory found" fi @@ -94,13 +116,25 @@ phases: # Use the standalone build properly: copy from .next/standalone + app's own build - echo "DEBUG - Building container from standalone + app build..." - # Copy the app's own built files (server.js, etc.) - - echo "Copying app's own .next build..." - - cp -r .next container-build/ || echo "App .next copy failed" + # Copy the COMPLETE standalone build first (includes server actions) + - echo "Copying complete standalone build..." + - cp -r .next/standalone/* container-build/ || echo "Standalone copy failed" + + # Then overlay the app's own .next directory (preserves server actions mapping) + - echo "Overlaying app's own .next build..." + - cp -r .next container-build/ || echo "App .next overlay failed" - # Copy shared node_modules from standalone (minimal runtime deps) - - echo "Copying standalone node_modules (runtime dependencies)..." - - cp -r .next/standalone/node_modules container-build/ || echo "Standalone node_modules copy failed" + # CRITICAL: Verify container has all necessary files + - echo "๐Ÿ” Verifying container build completeness..." + - ls -la container-build/.next/static/ || echo "No static directory in container" + - ls -la container-build/.next/server/ || echo "No server directory in container" + - CONTAINER_CHUNKS=$(find container-build/.next/static -name "*.js" 2>/dev/null | wc -l) + - CONTAINER_SERVER_FILES=$(find container-build/.next/server -name "*.js" 2>/dev/null | wc -l) + - 'echo "Container chunks: $CONTAINER_CHUNKS, Server files: $CONTAINER_SERVER_FILES"' + + - 'if [ "$CONTAINER_CHUNKS" -eq 0 ]; then echo "โŒ ERROR: No static chunks found in container build!" && exit 1; fi' + - 'if [ "$CONTAINER_SERVER_FILES" -eq 0 ]; then echo "โŒ ERROR: No server files found in container build!" && exit 1; fi' + - echo "โœ… Container build verification passed" # Copy or create server.js for standalone - | @@ -177,11 +211,23 @@ phases: - test -f container-build/server.js && echo "โœ… App-specific server.js found" || echo "โŒ App-specific server.js not found" - head -10 container-build/server.js || echo "โŒ Cannot read server.js" + # Ensure all S3 operations are complete before building container + - echo "โณ Waiting for S3 operations to fully complete..." + - sleep 5 + + # Final verification before Docker build + - echo "๐Ÿ” Final pre-build verification..." + - aws s3 ls s3://$STATIC_ASSETS_BUCKET/app/_next/static/ --recursive | tail -5 || echo "S3 listing failed" + # Build Docker image (static assets now served from S3) - - echo "Building Docker image..." + - echo "๐Ÿณ Building Docker image..." - docker build --build-arg BUILDKIT_INLINE_CACHE=1 -f container-build/Dockerfile -t $ECR_REPOSITORY_URI:$IMAGE_TAG container-build/ - docker tag $ECR_REPOSITORY_URI:$IMAGE_TAG $ECR_REPOSITORY_URI:latest + # Test container starts correctly (catch issues early) + - echo "๐Ÿงช Testing container startup..." + - timeout 30s docker run --rm -d -p 3001:3000 $ECR_REPOSITORY_URI:$IMAGE_TAG && echo "โœ… Container startup test passed" || echo "โš ๏ธ Container startup test failed (non-critical)" + post_build: commands: - echo "Pushing images to ECR..." @@ -196,7 +242,8 @@ cache: - 'node_modules/**/*' - 'packages/db/node_modules/**/*' - '/root/.bun/install/cache/**/*' - - '.next/cache/**/*' + # Exclude .next/cache to prevent stale server action mappings + # - '.next/cache/**/*' - 'bun.lock' artifacts: diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 6923bb672..350c5a70f 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -5,6 +5,8 @@ import './src/env.mjs'; const config: NextConfig = { // Use S3 bucket for static assets with app-specific path assetPrefix: process.env.NODE_ENV === 'production' ? `${process.env.STATIC_ASSETS_URL}/app` : '', + // Ensure fallback to local assets if CDN fails + generateEtags: false, poweredByHeader: false, reactStrictMode: true, transpilePackages: ['@trycompai/db'], @@ -29,12 +31,23 @@ const config: NextConfig = { experimental: { serverActions: { bodySizeLimit: '15mb', + // Ensure server actions are stable across builds + allowedOrigins: + process.env.NODE_ENV === 'production' + ? ([process.env.NEXT_PUBLIC_PORTAL_URL, 'https://app.trycomp.ai'].filter( + Boolean, + ) as string[]) + : undefined, }, authInterrupts: true, + // Improve build stability + optimizePackageImports: ['@trycompai/db', '@trycompai/ui'], }, outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingIncludes: { '/api/**/*': ['./node_modules/.prisma/client/**/*'], + // Ensure server actions are properly traced + '/**/*': ['./src/actions/**/*', './node_modules/.prisma/client/**/*'], }, async headers() { return [ @@ -60,6 +73,30 @@ const config: NextConfig = { }, ], }, + { + // Prevent caching of server action POST requests + source: '/(.*)', + has: [ + { + type: 'header', + key: 'Next-Action', + }, + ], + headers: [ + { + key: 'Cache-Control', + value: 'no-cache, no-store, must-revalidate', + }, + { + key: 'Pragma', + value: 'no-cache', + }, + { + key: 'Expires', + value: '0', + }, + ], + }, { // Apply security headers to all routes source: '/(.*)', diff --git a/apps/app/src/actions/files/upload-file.ts b/apps/app/src/actions/files/upload-file.ts index e4f90ff1e..ae78d553f 100644 --- a/apps/app/src/actions/files/upload-file.ts +++ b/apps/app/src/actions/files/upload-file.ts @@ -1,28 +1,29 @@ 'use server'; -console.log('[uploadFile] Upload action module is being loaded...'); - -console.log('[uploadFile] Importing auth and logger...'); import { BUCKET_NAME, s3Client } from '@/app/s3'; import { auth } from '@/utils/auth'; import { logger } from '@/utils/logger'; -import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { AttachmentEntityType, AttachmentType, db } from '@db'; import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { z } from 'zod'; -console.log('[uploadFile] Importing S3 client...'); - -console.log('[uploadFile] Importing AWS SDK...'); - -console.log('[uploadFile] Importing database...'); +// Configuration constants +const MAX_FILE_SIZE_MB = 10; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +const SIGNED_URL_EXPIRY = 900; // 15 minutes -console.log('[uploadFile] All imports successful'); - -// This log will run as soon as the module is loaded. -logger.info('[uploadFile] Module loaded.'); +// Allowed file types for security +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const ALLOWED_DOCUMENT_TYPES = [ + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; +const ALLOWED_FILE_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOCUMENT_TYPES]; function mapFileTypeToAttachmentType(fileType: string): AttachmentType { const type = fileType.split('/')[0]; @@ -40,112 +41,179 @@ function mapFileTypeToAttachmentType(fileType: string): AttachmentType { } } +// Enhanced validation schema const uploadAttachmentSchema = z.object({ - fileName: z.string(), - fileType: z.string(), - fileData: z.string(), - entityId: z.string(), + fileName: z + .string() + .min(1, 'File name is required') + .max(255, 'File name is too long') + .refine((name) => name.trim().length > 0, 'File name cannot be empty') + .refine((name) => !/[<>:"/\\|?*]/.test(name), 'File name contains invalid characters'), + fileType: z + .string() + .min(1, 'File type is required') + .refine( + (type) => ALLOWED_FILE_TYPES.includes(type), + `File type not allowed. Allowed types: ${ALLOWED_FILE_TYPES.join(', ')}`, + ), + fileData: z + .string() + .min(1, 'File data is required') + .refine((data) => { + try { + // Basic base64 validation + return /^[A-Za-z0-9+/]*={0,2}$/.test(data); + } catch { + return false; + } + }, 'Invalid file data format'), + entityId: z + .string() + .min(1, 'Entity ID is required') + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid entity ID format'), entityType: z.nativeEnum(AttachmentEntityType), pathToRevalidate: z.string().optional(), }); +// Custom error types for better error handling +class FileUploadError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + this.name = 'FileUploadError'; + } +} + +// Helper function to generate secure file key +function generateFileKey( + organizationId: string, + entityType: AttachmentEntityType, + entityId: string, + fileName: string, +): string { + const timestamp = Date.now(); + const randomId = Math.random().toString(36).substring(2, 15); + const sanitizedFileName = fileName + .replace(/[^a-zA-Z0-9.-]/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_|_$/g, ''); + + return `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${randomId}-${sanitizedFileName}`; +} + export const uploadFile = async (input: z.infer) => { - console.log('[uploadFile] Function called - starting execution'); - logger.info(`[uploadFile] Starting upload for ${input.fileName}`); + // Parse and validate input first - validation errors will be thrown automatically + const parsedInput = uploadAttachmentSchema.parse(input) as { + fileName: string; + fileType: string; + fileData: string; + entityId: string; + entityType: AttachmentEntityType; + pathToRevalidate?: string; + }; - console.log('[uploadFile] Checking S3 client availability'); - try { - // Check if S3 client is available - if (!s3Client) { - logger.error('[uploadFile] S3 client not initialized - check environment variables'); - return { - success: false, - error: 'File upload service is currently unavailable. Please contact support.', - } as const; - } + // Pre-flight checks + if (!s3Client) { + logger.error('[uploadFile] S3 client not initialized'); + throw new FileUploadError( + 'File upload service is currently unavailable. Please contact support.', + 'S3_CLIENT_UNAVAILABLE', + ); + } - if (!BUCKET_NAME) { - logger.error('[uploadFile] S3 bucket name not configured'); - return { - success: false, - error: 'File upload service is not properly configured.', - } as const; - } + if (!BUCKET_NAME) { + logger.error('[uploadFile] S3 bucket name not configured'); + throw new FileUploadError( + 'File upload service is not properly configured.', + 'S3_BUCKET_NOT_CONFIGURED', + ); + } - console.log('[uploadFile] Parsing input schema'); - const { fileName, fileType, fileData, entityId, entityType, pathToRevalidate } = - uploadAttachmentSchema.parse(input); + // Authentication check + const session = await auth.api.getSession({ headers: await headers() }); + const organizationId = session?.session.activeOrganizationId; - console.log('[uploadFile] Getting user session'); - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; + if (!organizationId) { + logger.error('[uploadFile] Not authorized - no organization found'); + throw new FileUploadError('Not authorized - no organization found', 'UNAUTHORIZED'); + } - if (!organizationId) { - logger.error('[uploadFile] Not authorized - no organization found'); - return { - success: false, - error: 'Not authorized - no organization found', - } as const; - } + logger.info(`[uploadFile] Starting upload for ${parsedInput.fileName} in org ${organizationId}`); - logger.info(`[uploadFile] Starting upload for ${fileName} in org ${organizationId}`); + let s3Key: string | null = null; - console.log('[uploadFile] Converting file data to buffer'); - const fileBuffer = Buffer.from(fileData, 'base64'); + try { + // Convert and validate file data + const fileBuffer = Buffer.from(parsedInput.fileData, 'base64'); - const MAX_FILE_SIZE_MB = 10; - const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { - logger.warn( - `[uploadFile] File size ${fileBuffer.length} exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, - ); - return { - success: false, - error: `File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, - } as const; + logger.warn(`[uploadFile] File size ${fileBuffer.length} exceeds limit`); + throw new FileUploadError(`File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, 'FILE_TOO_LARGE'); } - const timestamp = Date.now(); - const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`; + // Validate actual file size vs base64 size (base64 is ~33% larger) + const actualFileSize = Math.floor(fileBuffer.length * 0.75); + if (actualFileSize > MAX_FILE_SIZE_BYTES) { + throw new FileUploadError(`File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, 'FILE_TOO_LARGE'); + } - logger.info(`[uploadFile] Uploading to S3 with key: ${key}`); + // Generate secure file key + s3Key = generateFileKey( + organizationId, + parsedInput.entityType, + parsedInput.entityId, + parsedInput.fileName, + ); + + // Upload to S3 + logger.info(`[uploadFile] Uploading to S3 with key: ${s3Key}`); const putCommand = new PutObjectCommand({ Bucket: BUCKET_NAME, - Key: key, + Key: s3Key, Body: fileBuffer, - ContentType: fileType, + ContentType: parsedInput.fileType, + Metadata: { + originalFileName: parsedInput.fileName, + entityId: parsedInput.entityId, + entityType: parsedInput.entityType, + organizationId: organizationId, + }, }); + await s3Client.send(putCommand); - logger.info(`[uploadFile] S3 upload successful for key: ${key}`); + logger.info(`[uploadFile] S3 upload successful for key: ${s3Key}`); - logger.info(`[uploadFile] Creating attachment record in DB for key: ${key}`); + // Create database record + logger.info(`[uploadFile] Creating attachment record in DB for key: ${s3Key}`); const attachment = await db.attachment.create({ data: { - name: fileName, - url: key, - type: mapFileTypeToAttachmentType(fileType), - entityId: entityId, - entityType: entityType, + name: parsedInput.fileName, + url: s3Key, + type: mapFileTypeToAttachmentType(parsedInput.fileType), + entityId: parsedInput.entityId, + entityType: parsedInput.entityType, organizationId: organizationId, }, }); - logger.info(`[uploadFile] DB record created with id: ${attachment.id}`); - logger.info(`[uploadFile] Generating signed URL for key: ${key}`); + // Generate signed URL const getCommand = new GetObjectCommand({ Bucket: BUCKET_NAME, - Key: key, + Key: s3Key, }); const signedUrl = await getSignedUrl(s3Client, getCommand, { - expiresIn: 900, + expiresIn: SIGNED_URL_EXPIRY, }); - logger.info(`[uploadFile] Signed URL generated for key: ${key}`); - if (pathToRevalidate) { - revalidatePath(pathToRevalidate); + // Revalidate path if provided + if (parsedInput.pathToRevalidate) { + revalidatePath(parsedInput.pathToRevalidate); } + logger.info(`[uploadFile] Upload completed successfully for ${parsedInput.fileName}`); + return { success: true, data: { @@ -154,10 +222,36 @@ export const uploadFile = async (input: z.infer) }, } as const; } catch (error) { - logger.error(`[uploadFile] Error during upload process:`, error); + // Cleanup: If S3 upload succeeded but DB operation failed, clean up S3 object + if (s3Key && error instanceof Error && !error.message.includes('S3')) { + try { + logger.warn(`[uploadFile] Cleaning up S3 object after DB failure: ${s3Key}`); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: s3Key, + }), + ); + } catch (cleanupError) { + logger.error(`[uploadFile] Failed to cleanup S3 object: ${s3Key}`, cleanupError); + } + } + + // Enhanced error handling + if (error instanceof FileUploadError) { + logger.error(`[uploadFile] Upload failed: ${error.code} - ${error.message}`); + return { + success: false, + error: error.message, + code: error.code, + } as const; + } + + logger.error(`[uploadFile] Unexpected error during upload:`, error); return { success: false, error: error instanceof Error ? error.message : 'An unknown error occurred.', + code: 'UNKNOWN_ERROR', } as const; } }; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx index d10bed492..08ba6ccf3 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx @@ -26,6 +26,21 @@ interface TaskBodyProps { onAttachmentsChange?: () => void; } +// Helper function to provide user-friendly error messages +function getErrorMessage(errorMessage: string, errorCode?: string): string { + switch (errorCode) { + case 'FILE_TOO_LARGE': + return 'The file is too large. Please choose a file smaller than 10MB.'; + case 'S3_CLIENT_UNAVAILABLE': + case 'S3_BUCKET_NOT_CONFIGURED': + return 'File upload service is temporarily unavailable. Please try again later.'; + case 'UNAUTHORIZED': + return 'You are not authorized to upload files. Please refresh the page and try again.'; + default: + return errorMessage || 'Failed to upload file. Please try again.'; + } +} + export function TaskBody({ taskId, title, @@ -84,8 +99,11 @@ export function TaskBody({ onAttachmentsChange?.(); router.refresh(); } else { - console.error('File upload failed:', result.error); - toast.error(result.error || 'Failed to upload file. Check console for details.'); + console.error('File upload failed:', result.error, result.code); + + // Provide user-friendly error messages based on error codes + const userFriendlyMessage = getErrorMessage(result.error, result.code); + toast.error(userFriendlyMessage); } }; reader.onerror = () => { @@ -135,11 +153,14 @@ export function TaskBody({ toast.success(`File "${file.name}" uploaded successfully.`); resolve(result); } else { - throw new Error(result.error); + const userFriendlyMessage = getErrorMessage(result.error, result.code); + throw new Error(userFriendlyMessage); } } catch (error) { console.error(`Failed to upload ${file.name}:`, error); - toast.error(`Failed to upload ${file.name}.`); + toast.error( + `Failed to upload ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); resolve(null); // Resolve even if there's an error to not break Promise.all } };