diff --git a/api-dev-server.ts b/api-dev-server.ts index a5bfe2ab..e6f2d894 100644 --- a/api-dev-server.ts +++ b/api-dev-server.ts @@ -14,7 +14,7 @@ class MockVercelRequest implements VercelRequest { url: string; method: string; headers: IncomingMessage['headers']; - body: any; + body: unknown; query: { [key: string]: string | string[] }; cookies: { [key: string]: string }; @@ -61,13 +61,13 @@ class MockVercelResponse implements VercelResponse { return this; } - json(data: any): void { + json(data: unknown): void { this.setHeader('Content-Type', 'application/json'); this.res.writeHead(this.statusCode, this.headers); this.res.end(JSON.stringify(data)); } - send(data: any): void { + send(data: string | Buffer): void { this.res.writeHead(this.statusCode, this.headers); this.res.end(data); } @@ -152,8 +152,9 @@ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => res.writeHead(500); res.end(JSON.stringify({ error: 'Invalid API handler export' })); } - } catch (error: any) { - console.error(`Error handling ${endpoint}:`, error); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error(`Error handling ${endpoint}:`, errorMessage); res.writeHead(500); res.end(JSON.stringify({ error: 'Internal Server Error' diff --git a/api/create-checkout-session.ts b/api/create-checkout-session.ts index 882b0884..8e700e1d 100644 --- a/api/create-checkout-session.ts +++ b/api/create-checkout-session.ts @@ -112,19 +112,19 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const stripe = new Stripe(stripeSecretKey); - // Map planId to Stripe price IDs based on period + // Map planId to Stripe price IDs based on period (standardized naming) const priceIdMap: Record> = { 'pro': { - 'month': process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly', - 'year': process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly', + 'month': process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly', + 'year': process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly', }, 'enterprise': { - 'month': process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly', - 'year': process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly', + 'month': process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly', + 'year': process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly', }, 'starter': { - 'month': process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly', - 'year': process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly', + 'month': process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly', + 'year': process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly', }, }; diff --git a/api/get-subscription.ts b/api/get-subscription.ts index cc66e0c7..f3e0664b 100644 --- a/api/get-subscription.ts +++ b/api/get-subscription.ts @@ -1,6 +1,15 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import { getBearerOrSessionToken, verifyClerkToken } from './_utils/auth'; import Stripe from 'stripe'; +import { + StripeSubscription, + StripeCustomer, + SubscriptionData, + PlanType, + getSubscriptionPeriod, + isStripeSubscription, + isStripeCustomer +} from '../src/types/stripe'; function withCors(res: VercelResponse, allowOrigin?: string) { const origin = allowOrigin ?? process.env.PUBLIC_ORIGIN ?? '*'; @@ -132,26 +141,35 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { const subscription = subscriptions.data[0]; const priceId = subscription.items.data[0]?.price.id; - // Map Stripe price IDs back to plan IDs + // Map Stripe price IDs back to plan IDs (standardized naming) const planIdMap: Record = { - [process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro', - [process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro', - [process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise', - [process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise', - [process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter', - [process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter', + [process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro', + [process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro', + [process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise', + [process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise', + [process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter', + [process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter', }; const planId = planIdMap[priceId] || 'free'; - const subscriptionData = { - planId: planId, + // Type-safe period extraction + const subscriptionPeriod = getSubscriptionPeriod(subscription); + if (!subscriptionPeriod) { + console.error('Invalid subscription object structure'); + return withCors(res, allowedOrigin).status(500).json({ + error: 'Invalid subscription data' + }); + } + + const subscriptionData: SubscriptionData = { + planId: planId as PlanType, status: subscription.status, - currentPeriodStart: (subscription as any).current_period_start * 1000, // Convert to milliseconds - currentPeriodEnd: (subscription as any).current_period_end * 1000, // Convert to milliseconds + currentPeriodStart: subscriptionPeriod.currentPeriodStart, + currentPeriodEnd: subscriptionPeriod.currentPeriodEnd, cancelAtPeriodEnd: subscription.cancel_at_period_end, stripeSubscriptionId: subscription.id, - stripeCustomerId: customer.id, + stripeCustomerId: typeof customer === 'string' ? customer : customer.id, }; console.log('Retrieved subscription data for user:', authenticatedUserId, 'plan:', planId); diff --git a/api/stripe-webhook.ts b/api/stripe-webhook.ts index 17af14be..a642e837 100644 --- a/api/stripe-webhook.ts +++ b/api/stripe-webhook.ts @@ -1,15 +1,29 @@ import type { VercelRequest, VercelResponse } from '@vercel/node'; import Stripe from 'stripe'; - -// Helper function to map Stripe price IDs to plan types +import { + StripeSubscription, + StripeCustomer, + StripeInvoice, + StripeWebhookEvent, + PlanType, + getSubscriptionPeriod, + getCustomerEmail, + getCustomerMetadata, + getInvoiceSubscriptionId, + isStripeSubscription, + isStripeCustomer, + isStripeInvoice +} from '../src/types/stripe'; + +// Helper function to map Stripe price IDs to plan types (standardized naming) function mapPriceIdToPlanType(priceId: string): 'free' | 'pro' | 'enterprise' | 'starter' { const priceIdMap: Record = { - [process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro', - [process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro', - [process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise', - [process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise', - [process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter', - [process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter', + [process.env.STRIPE_PRICE_PRO_MONTH || process.env.STRIPE_PRO_MONTHLY_PRICE_ID || 'price_pro_monthly']: 'pro', + [process.env.STRIPE_PRICE_PRO_YEAR || process.env.STRIPE_PRO_YEARLY_PRICE_ID || 'price_pro_yearly']: 'pro', + [process.env.STRIPE_PRICE_ENTERPRISE_MONTH || process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID || 'price_enterprise_monthly']: 'enterprise', + [process.env.STRIPE_PRICE_ENTERPRISE_YEAR || process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID || 'price_enterprise_yearly']: 'enterprise', + [process.env.STRIPE_PRICE_STARTER_MONTH || process.env.STRIPE_STARTER_MONTHLY_PRICE_ID || 'price_starter_monthly']: 'starter', + [process.env.STRIPE_PRICE_STARTER_YEAR || process.env.STRIPE_STARTER_YEARLY_PRICE_ID || 'price_starter_yearly']: 'starter', }; return priceIdMap[priceId] || 'free'; } @@ -18,7 +32,7 @@ function mapPriceIdToPlanType(priceId: string): 'free' | 'pro' | 'enterprise' | // Simplified subscription sync - for now just log the webhook events // In production, you would want to use Convex HTTP actions or a queue system -async function logWebhookEvent(eventType: string, data: any): Promise { +async function logWebhookEvent(eventType: string, data: unknown): Promise { console.log(`[WEBHOOK] ${eventType}:`, JSON.stringify(data, null, 2)); // TODO: Implement proper Convex HTTP action calls or use a queue system @@ -53,6 +67,13 @@ async function syncSubscriptionToConvex( const priceId = subscription.items.data[0]?.price.id; const planType = mapPriceIdToPlanType(priceId); + // Type-safe period extraction + const subscriptionPeriod = getSubscriptionPeriod(subscription); + if (!subscriptionPeriod) { + console.error('Invalid subscription structure, cannot sync'); + return; + } + // Log the subscription sync await logWebhookEvent('subscription_sync', { userId, @@ -60,8 +81,8 @@ async function syncSubscriptionToConvex( planId: priceId, planType, status: subscription.status, - currentPeriodStart: (subscription as any).current_period_start * 1000, - currentPeriodEnd: (subscription as any).current_period_end * 1000, + currentPeriodStart: subscriptionPeriod.currentPeriodStart, + currentPeriodEnd: subscriptionPeriod.currentPeriodEnd, timestamp: now }); @@ -199,7 +220,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // Ensure customer mapping exists const customer = await stripe.customers.retrieve(session.customer as string); if (!customer.deleted) { - await ensureCustomerMapping(userId, customer.id, (customer as any).email || ''); + await ensureCustomerMapping(userId, customer.id, getCustomerEmail(customer)); } // Get the subscription ID from the session @@ -225,7 +246,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { break; } - const userId = !customer.deleted ? (customer as any).metadata?.userId : null; + const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null; if (!userId) { console.error('No userId found in customer metadata for subscription:', subscription.id); break; @@ -233,7 +254,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { // Ensure customer mapping exists if (!customer.deleted) { - await ensureCustomerMapping(userId, customer.id, (customer as any).email || ''); + await ensureCustomerMapping(userId, customer.id, getCustomerEmail(customer)); } // Sync subscription data @@ -253,7 +274,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { break; } - const userId = !customer.deleted ? (customer as any).metadata?.userId : null; + const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null; if (!userId) { console.error('No userId found in customer metadata for subscription:', subscription.id); break; @@ -275,7 +296,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { break; } - const userId = !customer.deleted ? (customer as any).metadata?.userId : null; + const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null; if (!userId) { console.error('No userId found in customer metadata for subscription:', subscription.id); break; @@ -292,8 +313,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { console.log('Payment succeeded for invoice:', invoice.id); // Get subscription if this is a subscription invoice - if ((invoice as any).subscription) { - const subscription = await stripe.subscriptions.retrieve((invoice as any).subscription as string); + const subscriptionId = getInvoiceSubscriptionId(invoice); + if (subscriptionId) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); const customer = await stripe.customers.retrieve(subscription.customer as string); if (customer.deleted) { @@ -301,7 +323,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { break; } - const userId = !customer.deleted ? (customer as any).metadata?.userId : null; + const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null; if (userId) { await syncSubscriptionToConvex(userId, subscription.customer as string, subscription); console.log('Successfully processed payment success for user:', userId); @@ -315,8 +337,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { console.log('Payment failed for invoice:', invoice.id); // Get subscription if this is a subscription invoice - if ((invoice as any).subscription) { - const subscription = await stripe.subscriptions.retrieve((invoice as any).subscription as string); + const subscriptionId = getInvoiceSubscriptionId(invoice); + if (subscriptionId) { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); const customer = await stripe.customers.retrieve(subscription.customer as string); if (customer.deleted) { @@ -324,7 +347,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { break; } - const userId = !customer.deleted ? (customer as any).metadata?.userId : null; + const userId = !customer.deleted ? getCustomerMetadata(customer).userId : null; if (userId) { // Sync the current subscription status (might be past_due) await syncSubscriptionToConvex(userId, subscription.customer as string, subscription); diff --git a/convex/users.ts b/convex/users.ts index e749ff08..f8669741 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -3,6 +3,7 @@ import { v } from "convex/values"; import { QueryCtx, MutationCtx } from "./_generated/server"; import { enforceRateLimit } from "./rateLimit"; import { api } from "./_generated/api"; +import { getSubscriptionPeriod } from "../src/types/stripe"; // Helper function to get authenticated user const getAuthenticatedUser = async (ctx: QueryCtx | MutationCtx) => { @@ -72,6 +73,77 @@ export const getCurrentUser = query({ }); // Create or update user profile +// Helper function to validate username availability +const validateUsernameAvailability = async (ctx: QueryCtx | MutationCtx, username: string | undefined, currentUserId: string) => { + if (!username) return; + + const existingUserWithUsername = await ctx.db + .query("users") + .withIndex("by_username", (q) => q.eq("username", username)) + .first(); + + if (existingUserWithUsername && existingUserWithUsername.userId !== currentUserId) { + throw new Error("Username is already taken"); + } +}; + +// Helper function to handle email conflicts and merging +type UserUpdateData = { + email?: string; + username?: string; + profileUrl?: string; + [key: string]: unknown; // Allow additional fields if needed +}; +const handleEmailConflict = async ( + ctx: MutationCtx, + email: string, + currentUserId: string, + sanitizedData: UserUpdateData, + now: number +) => { + const existingUserWithEmail = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", email)) + .first(); + + if (existingUserWithEmail && existingUserWithEmail.userId !== currentUserId) { + // Merge account by re-assigning the stored userId to the currently authenticated identity + await ctx.db.patch(existingUserWithEmail._id, { + userId: currentUserId, + ...sanitizedData, + updatedAt: now, + }); + return existingUserWithEmail._id; + } + + return null; // No conflict found +}; + +// Helper function to upsert user record +const upsertUserRecord = async (ctx: MutationCtx, userId: string, sanitizedData: any, now: number) => { + const existingUser = await ctx.db + .query("users") + .withIndex("by_user_id", (q) => q.eq("userId", userId)) + .first(); + + if (existingUser) { + // Update existing user + await ctx.db.patch(existingUser._id, { + ...sanitizedData, + updatedAt: now, + }); + return existingUser._id; + } else { + // Create new user + return await ctx.db.insert("users", { + userId, + ...sanitizedData, + createdAt: now, + updatedAt: now, + }); + } +}; + export const upsertUser = mutation({ args: { email: v.string(), @@ -82,13 +154,9 @@ export const upsertUser = mutation({ }, handler: async (ctx, args) => { const identity = await getAuthenticatedUser(ctx); - - // Enforce rate limiting await enforceRateLimit(ctx, "upsertUser"); const now = Date.now(); - - // Sanitize and validate inputs const sanitizedData = { email: sanitizeEmail(args.email), fullName: sanitizeString(args.fullName, 100), @@ -97,62 +165,17 @@ export const upsertUser = mutation({ bio: sanitizeString(args.bio, 500), }; - // Check if username is taken by another user - if (sanitizedData.username) { - const existingUserWithUsername = await ctx.db - .query("users") - .withIndex("by_username", (q) => q.eq("username", sanitizedData.username)) - .first(); - - if (existingUserWithUsername && existingUserWithUsername.userId !== identity.subject) { - throw new Error("Username is already taken"); - } - } - - // Check if email is taken by another user and merge if necessary - const existingUserWithEmail = await ctx.db - .query("users") - .withIndex("by_email", (q) => q.eq("email", sanitizedData.email)) - .first(); - - if (existingUserWithEmail && existingUserWithEmail.userId !== identity.subject) { - // We found a record with the same email but a different userId. Instead of throwing - // an error (which breaks login flows when a user accidentally creates a new Clerk - // account with the same email), we transparently merge the account by re-assigning - // the stored `userId` to the currently authenticated identity. + // Validate username availability + await validateUsernameAvailability(ctx, sanitizedData.username, identity.subject); - await ctx.db.patch(existingUserWithEmail._id, { - userId: identity.subject, - ...sanitizedData, - updatedAt: now, - }); - - return existingUserWithEmail._id; + // Handle email conflicts and potential account merging + const mergedUserId = await handleEmailConflict(ctx, sanitizedData.email, identity.subject, sanitizedData, now); + if (mergedUserId) { + return mergedUserId; } - // Use proper index query instead of filter - const existingUser = await ctx.db - .query("users") - .withIndex("by_user_id", (q) => q.eq("userId", identity.subject)) - .first(); - - if (existingUser) { - // Update existing user - await ctx.db.patch(existingUser._id, { - ...sanitizedData, - updatedAt: now, - }); - return existingUser._id; - } else { - // Create new user - const userId = await ctx.db.insert("users", { - userId: identity.subject, - ...sanitizedData, - createdAt: now, - updatedAt: now, - }); - return userId; - } + // Upsert user record + return await upsertUserRecord(ctx, identity.subject, sanitizedData, now); }, }); @@ -423,8 +446,16 @@ export const syncStripeDataToConvex = mutation({ status: mappedStatus, priceId, planId, - currentPeriodStart: (subscription as any).current_period_start, - currentPeriodEnd: (subscription as any).current_period_end, + ...(() => { + const period = getSubscriptionPeriod(subscription); + return period ? { + currentPeriodStart: Math.floor(period.currentPeriodStart / 1000), // Convert back to seconds for Convex + currentPeriodEnd: Math.floor(period.currentPeriodEnd / 1000), + } : { + currentPeriodStart: 0, + currentPeriodEnd: 0, + }; + })(), cancelAtPeriodEnd: !!subscription.cancel_at_period_end, paymentMethod, lastSyncAt: now, @@ -595,8 +626,18 @@ function mapSubscriptionStatus(stripeStatus: string): 'incomplete' | 'incomplete } function mapPriceIdToPlan(priceId: string): 'free' | 'pro' | 'enterprise' { - const pro = [process.env.STRIPE_PRICE_PRO_MONTH, process.env.STRIPE_PRICE_PRO_YEAR].filter(Boolean); - const enterprise = [process.env.STRIPE_PRICE_ENTERPRISE_MONTH, process.env.STRIPE_PRICE_ENTERPRISE_YEAR].filter(Boolean); + const pro = [ + process.env.STRIPE_PRICE_PRO_MONTH, + process.env.STRIPE_PRICE_PRO_YEAR, + process.env.STRIPE_PRO_MONTHLY_PRICE_ID, + process.env.STRIPE_PRO_YEARLY_PRICE_ID + ].filter(Boolean); + const enterprise = [ + process.env.STRIPE_PRICE_ENTERPRISE_MONTH, + process.env.STRIPE_PRICE_ENTERPRISE_YEAR, + process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID, + process.env.STRIPE_ENTERPRISE_YEARLY_PRICE_ID + ].filter(Boolean); if (enterprise.includes(priceId)) return 'enterprise'; if (pro.includes(priceId)) return 'pro'; diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index 1fed47d2..33c7ba10 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -977,167 +977,19 @@ const ChatInterface: React.FC = () => { - - - - - - - - - Website Analysis & Cloning - - -
-
- setWebsiteUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleWebsiteAnalysis()} - className="flex-1" - /> - -
- - {websiteAnalysis && ( - -

Website Analysis:

-
-
- - {websiteAnalysis.title && ( -
Title: {websiteAnalysis.title}
- )} - {websiteAnalysis.description && ( -
Description: {websiteAnalysis.description}
- )} - {websiteAnalysis.technologies && websiteAnalysis.technologies.length > 0 && ( -
- Technologies: -
- {websiteAnalysis.technologies.map((tech, index) => ( - {tech} - ))} -
-
- )} - {websiteAnalysis.layout && ( -
Layout: {websiteAnalysis.layout}
- )} - {websiteAnalysis.components && websiteAnalysis.components.length > 0 && ( -
- Components: -
- {websiteAnalysis.components.map((comp, index) => ( - {comp} - ))} -
-
- )} - {websiteAnalysis.colorScheme && websiteAnalysis.colorScheme.length > 0 && ( -
- Colors: -
- {websiteAnalysis.colorScheme.slice(0, 8).map((color, index) => ( -
- ))} -
-
- )} -
-
- - -
- {crawlPages.length > 0 && ( -
-
Crawled Pages
-
- {crawlPages.slice(0, 20).map((p, i) => ( - - ))} -
-
- )} - -
- - )} -
- -
+ {/* Clone website feature simplified - button now directly adds prompt to chat */} + {/* Feature highlights */} diff --git a/src/components/EnhancedChatInterface.tsx b/src/components/EnhancedChatInterface.tsx index 38974e2a..8eca01de 100644 --- a/src/components/EnhancedChatInterface.tsx +++ b/src/components/EnhancedChatInterface.tsx @@ -1,43 +1,8 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback, memo } from 'react'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { Card, CardContent } from '@/components/ui/card'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Separator } from '@/components/ui/separator'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { SafeText } from '@/components/ui/SafeText'; -import { - Send, - User, - Bot, - Play, - Copy, - Check, - Plus, - MessageSquare, - Trash2, - Edit3, - Sparkles, - Clock, - Zap, - Loader2, - Search, - Globe, - ExternalLink, - Link, - Code, - Palette, - Layers, - ArrowUp, - Mic, - Paperclip, - Settings, - Github, - GitBranch -} from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { motion, AnimatePresence } from 'framer-motion'; import { useQuery, useMutation } from 'convex/react'; import { api } from '../../convex/_generated/api'; @@ -48,159 +13,26 @@ import { executeCode, startSandbox } from '@/lib/sandbox'; import { useAuth } from '@/hooks/useAuth'; import { useAuth as useClerkAuth } from '@clerk/clerk-react'; import { useUsageTracking } from '@/hooks/useUsageTracking'; -import AnimatedResultShowcase from './AnimatedResultShowcase'; -import { braveSearchService, type BraveSearchResult, type WebsiteAnalysis } from '@/lib/search-service'; -import { crawlSite } from '@/lib/firecrawl'; import { toast } from 'sonner'; -import * as Sentry from '@sentry/react'; -import WebContainerFailsafe from './WebContainerFailsafe'; -import { DECISION_PROMPT_NEXT } from '@/lib/decisionPrompt'; -import { GitHubIntegration } from '@/components/GitHubIntegration'; -import type { GitHubRepo } from '@/lib/github-service'; -import { githubService } from '@/lib/github-service'; -import DiagramMessageComponent from './DiagramMessageComponent'; - -const { logger } = Sentry; - -// Memoized Enhanced Message Component -const EnhancedMessageComponent = memo(({ message, user, isUser, isFirstInGroup, formatTimestamp, copyToClipboard, copiedMessage, onApproveDiagram, onRequestDiagramChanges, isSubmittingDiagram }: { - message: ConvexMessage; - user: { avatarUrl?: string; email?: string; fullName?: string } | null; - isUser: boolean; - isFirstInGroup: boolean; - formatTimestamp: (timestamp: number) => string; - copyToClipboard: (text: string, messageId: string) => Promise; - copiedMessage: string | null; - onApproveDiagram?: (messageId: string) => Promise; - onRequestDiagramChanges?: (messageId: string, feedback: string) => Promise; - isSubmittingDiagram?: boolean; -}) => { - const hasDiagram = message.metadata?.diagramData; - - return ( -
- - -
- - {message.content} - - - {message.metadata?.model && ( -
- - {message.metadata.model} - - {message.metadata.tokens && ( - {message.metadata.tokens} tokens - )} - {message.metadata.cost && ( - ${message.metadata.cost.toFixed(4)} - )} - {hasDiagram && ( - - Contains Diagram - - )} -
- )} -
- -
- -
- -
- {formatTimestamp(message.createdAt)} -
-
-
- {/* Diagram Component */} - {hasDiagram && onApproveDiagram && onRequestDiagramChanges && ( - - )} -
- ); -}); - -// Security constants for input validation -const MAX_MESSAGE_LENGTH = 10000; -const MAX_TITLE_LENGTH = 100; -const MIN_TITLE_LENGTH = 1; +// Import extracted components +import { ChatSidebar } from './chat/ChatSidebar'; +import { ChatMessage } from './chat/ChatMessage'; +import { ChatInput } from './chat/ChatInput'; +import { WelcomeScreen } from './chat/WelcomeScreen'; +import { ErrorBoundary } from './ErrorBoundary'; +import GitHubIntegration from './GitHubIntegration'; +import DiagramMessageComponent from './DiagramMessageComponent'; -// XSS protection: sanitize text input -const sanitizeText = (text: string): string => { - return text - .replace(/[<>'"&]/g, (char) => { - const chars: { [key: string]: string } = { - '<': '<', - '>': '>', - "'": ''', - '"': '"', - '&': '&' - }; - return chars[char] || char; - }) - .trim(); -}; +// Import utilities +import { validateInput, MAX_MESSAGE_LENGTH } from '@/utils/security'; +import { throttle, debounce } from '@/utils/performance'; -// Validate input length and content -const validateInput = (text: string, maxLength: number): { isValid: boolean; error?: string } => { - if (!text || text.trim().length === 0) { - return { isValid: false, error: 'Input cannot be empty' }; - } - if (text.length > maxLength) { - return { isValid: false, error: `Input too long. Maximum ${maxLength} characters allowed` }; - } - // Check for potentially malicious patterns - const suspiciousPatterns = [ - /')).toBe('<script>alert("xss")</script>'); + expect(sanitizeText('')).toBe('<img src="x" onerror="alert(1)">'); + expect(sanitizeText("javascript:alert('xss')")).toBe("javascript:alert('xss')"); + }); + + it('should handle ampersands correctly', () => { + expect(sanitizeText('Ben & Jerry')).toBe('Ben & Jerry'); + expect(sanitizeText('A & B & C')).toBe('A & B & C'); + }); + + it('should preserve safe text', () => { + expect(sanitizeText('Hello world!')).toBe('Hello world!'); + expect(sanitizeText('This is a normal message.')).toBe('This is a normal message.'); + expect(sanitizeText('123456789')).toBe('123456789'); + }); + + it('should trim whitespace', () => { + expect(sanitizeText(' hello world ')).toBe('hello world'); + expect(sanitizeText('\n\ttab and newline\n\t')).toBe('tab and newline'); + }); + + it('should handle empty strings', () => { + expect(sanitizeText('')).toBe(''); + expect(sanitizeText(' ')).toBe(''); + }); + + it('should handle mixed content', () => { + const mixed = 'Hello world & "friends"!'; + const expected = 'Hello <b>world</b> & "friends"!'; + expect(sanitizeText(mixed)).toBe(expected); + }); + }); + + describe('validateInput', () => { + it('should validate normal input', () => { + const result = validateInput('Hello world', 100); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should reject non-string input', () => { + const result = validateInput(null as any, 100); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Input must be a non-empty string'); + }); + + it('should reject empty strings', () => { + const result = validateInput('', 100); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Input cannot be empty or only whitespace'); + }); + + it('should reject whitespace-only strings', () => { + const result = validateInput(' \n\t ', 100); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Input cannot be empty or only whitespace'); + }); + + it('should reject input exceeding max length', () => { + const longText = 'a'.repeat(101); + const result = validateInput(longText, 100); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Input exceeds maximum length of 100 characters'); + }); + + it('should accept input at max length boundary', () => { + const maxText = 'a'.repeat(100); + const result = validateInput(maxText, 100); + expect(result.isValid).toBe(true); + }); + }); + + describe('validateChatTitle', () => { + it('should validate normal titles', () => { + const result = validateChatTitle('My Chat Title'); + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should reject non-string titles', () => { + const result = validateChatTitle(undefined as any); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Title must be a non-empty string'); + }); + + it('should reject empty titles', () => { + const result = validateChatTitle(''); + expect(result.isValid).toBe(false); + expect(result.error).toBe(`Title must be at least ${MIN_TITLE_LENGTH} character(s)`); + }); + + it('should reject titles that are too long', () => { + const longTitle = 'a'.repeat(MAX_TITLE_LENGTH + 1); + const result = validateChatTitle(longTitle); + expect(result.isValid).toBe(false); + expect(result.error).toBe(`Title exceeds maximum length of ${MAX_TITLE_LENGTH} characters`); + }); + + it('should detect XSS patterns in titles', () => { + const xssTests = [ + '', + 'javascript:alert(1)', + '', + 'onclick="alert(1)"', + '', + '' + ]; + + xssTests.forEach(xssTitle => { + const result = validateChatTitle(xssTitle); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Title contains potentially unsafe content'); + }); + }); + + it('should allow safe HTML-like text', () => { + // These look like HTML but aren't actually dangerous + const safeTests = [ + 'My Project v2.0', + 'API < Database Integration', + 'Component A > Component B', + 'Settings & Configuration' + ]; + + safeTests.forEach(safeTitle => { + const result = validateChatTitle(safeTitle); + expect(result.isValid).toBe(true); + }); + }); + + it('should handle title trimming', () => { + const result = validateChatTitle(' My Title '); + expect(result.isValid).toBe(true); + }); + }); + + describe('RateLimiter', () => { + let rateLimiter: RateLimiter; + + beforeEach(() => { + rateLimiter = new RateLimiter(3, 1000); // 3 attempts per second + }); + + it('should allow requests within limit', () => { + expect(rateLimiter.canMakeRequest('user1')).toBe(true); + expect(rateLimiter.canMakeRequest('user1')).toBe(true); + expect(rateLimiter.canMakeRequest('user1')).toBe(true); + }); + + it('should block requests exceeding limit', () => { + // Use up the limit + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + + // This should be blocked + expect(rateLimiter.canMakeRequest('user1')).toBe(false); + }); + + it('should track different users separately', () => { + // Use up limit for user1 + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + + // user2 should still be able to make requests + expect(rateLimiter.canMakeRequest('user2')).toBe(true); + expect(rateLimiter.canMakeRequest('user2')).toBe(true); + }); + + it('should calculate remaining attempts correctly', () => { + expect(rateLimiter.getRemainingAttempts('user1')).toBe(3); + + rateLimiter.canMakeRequest('user1'); + expect(rateLimiter.getRemainingAttempts('user1')).toBe(2); + + rateLimiter.canMakeRequest('user1'); + expect(rateLimiter.getRemainingAttempts('user1')).toBe(1); + + rateLimiter.canMakeRequest('user1'); + expect(rateLimiter.getRemainingAttempts('user1')).toBe(0); + }); + + it('should reset user limits', () => { + // Use up the limit + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + rateLimiter.canMakeRequest('user1'); + + expect(rateLimiter.canMakeRequest('user1')).toBe(false); + + // Reset and try again + rateLimiter.reset('user1'); + expect(rateLimiter.canMakeRequest('user1')).toBe(true); + }); + + it('should handle time-based resets', (done) => { + const shortLimiter = new RateLimiter(2, 100); // 2 attempts per 100ms + + // Use up the limit + shortLimiter.canMakeRequest('user1'); + shortLimiter.canMakeRequest('user1'); + expect(shortLimiter.canMakeRequest('user1')).toBe(false); + + // Wait for the time window to pass + setTimeout(() => { + expect(shortLimiter.canMakeRequest('user1')).toBe(true); + done(); + }, 150); + }); + }); + + describe('Constants', () => { + it('should have sensible security constants', () => { + expect(MAX_MESSAGE_LENGTH).toBeGreaterThan(0); + expect(MAX_MESSAGE_LENGTH).toBeLessThan(100000); // Reasonable upper bound + + expect(MAX_TITLE_LENGTH).toBeGreaterThan(0); + expect(MAX_TITLE_LENGTH).toBeLessThan(1000); + + expect(MIN_TITLE_LENGTH).toBeGreaterThan(0); + expect(MIN_TITLE_LENGTH).toBeLessThanOrEqual(MAX_TITLE_LENGTH); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/performance.ts b/src/utils/performance.ts new file mode 100644 index 00000000..fb2dca6a --- /dev/null +++ b/src/utils/performance.ts @@ -0,0 +1,49 @@ +/** + * Performance Utility Functions + * Extracted from EnhancedChatInterface for better code organization + */ + +// Performance utility functions +export const throttle = any>(func: T, limit: number): T => { + let inThrottle: boolean; + return ((...args: any[]) => { + if (!inThrottle) { + func.apply(null, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }) as T; +}; + +export const debounce = any>(func: T, delay: number): T => { + let timeoutId: NodeJS.Timeout; + return ((...args: any[]) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(null, args), delay); + }) as T; +}; + +// Memory monitoring utilities +export class PerformanceMonitor { + private static instance: PerformanceMonitor; + + static getInstance(): PerformanceMonitor { + if (!PerformanceMonitor.instance) { + PerformanceMonitor.instance = new PerformanceMonitor(); + } + return PerformanceMonitor.instance; + } + + measureMemoryUsage(): number { + if ('memory' in performance) { + const memory = (performance as any).memory; + return memory.usedJSHeapSize; + } + return 0; + } + + logPerformanceMetrics(componentName: string): void { + const memory = this.measureMemoryUsage(); + console.log(`[PERFORMANCE] ${componentName}: ${(memory / 1024 / 1024).toFixed(2)} MB`); + } +} \ No newline at end of file diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 00000000..ff60a4b4 --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,115 @@ +/** + * Security and Input Validation Utilities + * Extracted from EnhancedChatInterface for better security management + */ + +// Security constants for input validation +export const MAX_MESSAGE_LENGTH = 10000; +export const MAX_TITLE_LENGTH = 100; +export const MIN_TITLE_LENGTH = 1; + +// XSS protection: sanitize text input +export const sanitizeText = (text: string): string => { + return text + .replace(/[<>'"&]/g, (char) => { + const chars: { [key: string]: string } = { + '<': '<', + '>': '>', + "'": ''', + '"': '"', + '&': '&' + }; + return chars[char] || char; + }) + .trim(); +}; + +// Validate input length and content +export const validateInput = (text: string, maxLength: number): { isValid: boolean; error?: string } => { + if (!text || typeof text !== 'string') { + return { isValid: false, error: 'Input must be a non-empty string' }; + } + + if (text.trim().length === 0) { + return { isValid: false, error: 'Input cannot be empty or only whitespace' }; + } + + if (text.length > maxLength) { + return { isValid: false, error: `Input exceeds maximum length of ${maxLength} characters` }; + } + + return { isValid: true }; +}; + +// Validate chat title +export const validateChatTitle = (title: string): { isValid: boolean; error?: string } => { + if (!title || typeof title !== 'string') { + return { isValid: false, error: 'Title must be a non-empty string' }; + } + + const trimmedTitle = title.trim(); + + if (trimmedTitle.length < MIN_TITLE_LENGTH) { + return { isValid: false, error: `Title must be at least ${MIN_TITLE_LENGTH} character(s)` }; + } + + if (trimmedTitle.length > MAX_TITLE_LENGTH) { + return { isValid: false, error: `Title exceeds maximum length of ${MAX_TITLE_LENGTH} characters` }; + } + + // Check for potential XSS patterns + const xssPatterns = [ + /