diff --git a/src/config.ts b/src/config.ts index b3d6dc5..5de5d89 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,6 +4,21 @@ import type { UseCaseConfig } from './types'; +/** + * System limits and configuration constants + */ +export const LIMITS = { + MAX_REQUEST_BODY_BYTES: 10240, // 10KB + CACHE_TTL_SECONDS: 300, // 5 minutes + RATE_LIMIT_MAX_REQUESTS: 60, // per minute + RATE_LIMIT_WINDOW_MS: 60000, // 1 minute + VPS_BENCHMARK_LIMIT: 20, + MAX_AI_CANDIDATES: 15, // Reduce from 50 to save tokens + MAX_TECH_STACK: 20, + MAX_USE_CASE_LENGTH: 500, + MAX_REGION_PREFERENCE: 10, +} as const; + export const USE_CASE_CONFIGS: UseCaseConfig[] = [ { category: 'video', diff --git a/src/handlers/recommend.ts b/src/handlers/recommend.ts index 7d613e7..5b9775b 100644 --- a/src/handlers/recommend.ts +++ b/src/handlers/recommend.ts @@ -26,7 +26,8 @@ import { isValidBenchmarkData, isValidVPSBenchmark, isValidTechSpec, - isValidAIRecommendation + isValidAIRecommendation, + DEFAULT_REGION_FILTER_SQL } from '../utils'; export async function handleRecommend( @@ -430,22 +431,7 @@ async function queryCandidateServers( query += ` AND (${regionConditions.join(' OR ')})`; } else { // No region specified → default to Seoul/Tokyo/Osaka/Singapore - query += ` AND ( - -- Korea (Seoul) - r.region_code IN ('icn', 'ap-northeast-2') OR - LOWER(r.region_name) LIKE '%seoul%' OR - -- Japan (Tokyo, Osaka) - r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR - LOWER(r.region_code) LIKE '%tyo%' OR - LOWER(r.region_code) LIKE '%osa%' OR - LOWER(r.region_name) LIKE '%tokyo%' OR - LOWER(r.region_name) LIKE '%osaka%' OR - -- Singapore - r.region_code IN ('sgp', 'ap-southeast-1') OR - LOWER(r.region_code) LIKE '%sin%' OR - LOWER(r.region_code) LIKE '%sgp%' OR - LOWER(r.region_name) LIKE '%singapore%' - )`; + query += ` AND ${DEFAULT_REGION_FILTER_SQL}`; } // Filter by provider if specified @@ -936,6 +922,14 @@ ${languageInstruction}`; const benchmarkSummary = formatBenchmarkSummary(benchmarkData); const vpsBenchmarkSummary = formatVPSBenchmarkSummary(vpsBenchmarks); + // Pre-filter candidates to reduce AI prompt size and cost + // Sort by price and limit to top 15 most affordable options + const topCandidates = candidates + .sort((a, b) => a.monthly_price - b.monthly_price) + .slice(0, 15); + + console.log(`[AI] Filtered ${candidates.length} candidates to ${topCandidates.length} for AI analysis`); + const userPrompt = `Analyze these server options and recommend the top 3 best matches. ## User Requirements @@ -956,7 +950,7 @@ ${vpsBenchmarkSummary || 'No similar VPS benchmark data available.'} ${benchmarkSummary || 'No relevant CPU benchmark data available.'} ## Available Servers (IMPORTANT: Use the server_id value, NOT the list number!) -${candidates.map((s) => ` +${topCandidates.map((s) => ` [server_id=${s.id}] ${s.provider_name} - ${s.instance_name}${s.instance_family ? ` (${s.instance_family})` : ''} Instance: ${s.instance_id} vCPU: ${s.vcpu} | Memory: ${s.memory_gb} GB | Storage: ${s.storage_gb} GB diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts index 8cb7785..b61b0b3 100644 --- a/src/handlers/servers.ts +++ b/src/handlers/servers.ts @@ -3,7 +3,7 @@ */ import type { Env } from '../types'; -import { jsonResponse, isValidServer } from '../utils'; +import { jsonResponse, isValidServer, DEFAULT_REGION_FILTER_SQL } from '../utils'; /** * GET /api/servers - Server list with filtering @@ -49,23 +49,8 @@ export async function handleGetServers( JOIN providers p ON it.provider_id = p.id JOIN pricing pr ON pr.instance_type_id = it.id JOIN regions r ON pr.region_id = r.id - WHERE p.id IN (1, 2) -- Linode, Vultr only - AND ( - -- Korea (Seoul) - r.region_code IN ('icn', 'ap-northeast-2') OR - LOWER(r.region_name) LIKE '%seoul%' OR - -- Japan (Tokyo, Osaka) - r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR - LOWER(r.region_code) LIKE '%tyo%' OR - LOWER(r.region_code) LIKE '%osa%' OR - LOWER(r.region_name) LIKE '%tokyo%' OR - LOWER(r.region_name) LIKE '%osaka%' OR - -- Singapore - r.region_code IN ('sgp', 'ap-southeast-1') OR - LOWER(r.region_code) LIKE '%sin%' OR - LOWER(r.region_code) LIKE '%sgp%' OR - LOWER(r.region_name) LIKE '%singapore%' - ) + WHERE LOWER(p.name) IN ('linode', 'vultr') + AND ${DEFAULT_REGION_FILTER_SQL} `; const params: (string | number)[] = []; diff --git a/src/utils.ts b/src/utils.ts index dbaec10..6e0054b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -14,7 +14,7 @@ import type { BandwidthEstimate, BandwidthInfo } from './types'; -import { USE_CASE_CONFIGS, i18n } from './config'; +import { USE_CASE_CONFIGS, i18n, LIMITS } from './config'; /** * JSON response helper @@ -110,6 +110,27 @@ export function generateCacheKey(req: RecommendRequest): string { return `recommend:${parts.join('|')}`; } +/** + * Default region filter SQL for when no region is specified + * Used in both /api/recommend and /api/servers + */ +export const DEFAULT_REGION_FILTER_SQL = `( + -- Korea (Seoul) + r.region_code IN ('icn', 'ap-northeast-2') OR + LOWER(r.region_name) LIKE '%seoul%' OR + -- Japan (Tokyo, Osaka) + r.region_code IN ('nrt', 'itm', 'ap-northeast-1', 'ap-northeast-3') OR + LOWER(r.region_code) LIKE '%tyo%' OR + LOWER(r.region_code) LIKE '%osa%' OR + LOWER(r.region_name) LIKE '%tokyo%' OR + LOWER(r.region_name) LIKE '%osaka%' OR + -- Singapore + r.region_code IN ('sgp', 'ap-southeast-1') OR + LOWER(r.region_code) LIKE '%sin%' OR + LOWER(r.region_code) LIKE '%sgp%' OR + LOWER(r.region_name) LIKE '%singapore%' +)`; + /** * Escape LIKE pattern special characters */ @@ -216,8 +237,8 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida missingFields.push('tech_stack'); } else if (!Array.isArray(body.tech_stack) || body.tech_stack.length === 0) { invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' }); - } else if (body.tech_stack.length > 20) { - invalidFields.push({ field: 'tech_stack', reason: 'must not exceed 20 items' }); + } else if (body.tech_stack.length > LIMITS.MAX_TECH_STACK) { + invalidFields.push({ field: 'tech_stack', reason: `must not exceed ${LIMITS.MAX_TECH_STACK} items` }); } else if (!body.tech_stack.every((item: any) => typeof item === 'string')) { invalidFields.push({ field: 'tech_stack', reason: 'all items must be strings' }); } @@ -234,8 +255,8 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida missingFields.push('use_case'); } else if (typeof body.use_case !== 'string' || body.use_case.trim().length === 0) { invalidFields.push({ field: 'use_case', reason: 'must be a non-empty string' }); - } else if (body.use_case.length > 500) { - invalidFields.push({ field: 'use_case', reason: 'must not exceed 500 characters' }); + } else if (body.use_case.length > LIMITS.MAX_USE_CASE_LENGTH) { + invalidFields.push({ field: 'use_case', reason: `must not exceed ${LIMITS.MAX_USE_CASE_LENGTH} characters` }); } // Check optional fields if provided @@ -246,8 +267,8 @@ export function validateRecommendRequest(body: any, lang: string = 'en'): Valida if (body.region_preference !== undefined) { if (!Array.isArray(body.region_preference)) { invalidFields.push({ field: 'region_preference', reason: 'must be an array' }); - } else if (body.region_preference.length > 10) { - invalidFields.push({ field: 'region_preference', reason: 'must not exceed 10 items' }); + } else if (body.region_preference.length > LIMITS.MAX_REGION_PREFERENCE) { + invalidFields.push({ field: 'region_preference', reason: `must not exceed ${LIMITS.MAX_REGION_PREFERENCE} items` }); } else if (!body.region_preference.every((item: any) => typeof item === 'string')) { invalidFields.push({ field: 'region_preference', reason: 'all items must be strings' }); } @@ -594,18 +615,54 @@ export function calculateBandwidthInfo( } /** - * Rate limiting check using KV storage + * Sanitize user input for AI prompts to prevent prompt injection + */ +export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string { + // Remove potential prompt injection patterns + let sanitized = input + .replace(/ignore\s*(all|previous|above)?\s*instruction/gi, '[filtered]') + .replace(/system\s*prompt/gi, '[filtered]') + .replace(/you\s*are\s*(now|a)/gi, '[filtered]') + .replace(/pretend\s*(to\s*be|you)/gi, '[filtered]') + .replace(/act\s*as/gi, '[filtered]') + .replace(/disregard/gi, '[filtered]'); + + // Limit length + return sanitized.slice(0, maxLength); +} + +// In-memory fallback for rate limiting when CACHE KV is unavailable +const inMemoryRateLimit = new Map(); + +/** + * Rate limiting check using KV storage with in-memory fallback */ export async function checkRateLimit(clientIP: string, env: import('./types').Env): Promise<{ allowed: boolean; requestId: string }> { const requestId = crypto.randomUUID(); + const now = Date.now(); + const maxRequests = LIMITS.RATE_LIMIT_MAX_REQUESTS; + const windowMs = LIMITS.RATE_LIMIT_WINDOW_MS; - // If CACHE is not configured, allow the request + // Use in-memory fallback if CACHE unavailable if (!env.CACHE) { + const record = inMemoryRateLimit.get(clientIP); + + if (!record || record.resetTime < now) { + // New window or expired + inMemoryRateLimit.set(clientIP, { count: 1, resetTime: now + windowMs }); + return { allowed: true, requestId }; + } + + if (record.count >= maxRequests) { + return { allowed: false, requestId }; + } + + // Increment count + record.count++; return { allowed: true, requestId }; } - const now = Date.now(); - const maxRequests = 60; + // KV-based rate limiting const kvKey = `ratelimit:${clientIP}`; try { @@ -616,7 +673,7 @@ export async function checkRateLimit(clientIP: string, env: import('./types').En // New window await env.CACHE.put( kvKey, - JSON.stringify({ count: 1, resetTime: now + 60000 }), + JSON.stringify({ count: 1, resetTime: now + windowMs }), { expirationTtl: 60 } ); return { allowed: true, requestId };