diff --git a/migrations/004_add_idempotency_key.sql b/migrations/004_add_idempotency_key.sql new file mode 100644 index 0000000..e666f62 --- /dev/null +++ b/migrations/004_add_idempotency_key.sql @@ -0,0 +1,17 @@ +-- Migration: Add idempotency_key column to server_orders table +-- Purpose: Prevent duplicate order creation on Queue retry +-- Date: 2026-01-28 + +-- Note: This migration should be run on telegram-conversations database (USER_DB) +-- SQLite doesn't allow adding UNIQUE column directly, so we add column + UNIQUE INDEX + +-- Step 1: Add idempotency_key column (without UNIQUE constraint) +ALTER TABLE server_orders ADD COLUMN idempotency_key TEXT; + +-- Step 2: Create UNIQUE index (this enforces uniqueness for non-NULL values) +CREATE UNIQUE INDEX IF NOT EXISTS idx_server_orders_idempotency_unique + ON server_orders(idempotency_key) + WHERE idempotency_key IS NOT NULL; + +-- Verification query (run after migration): +-- SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='server_orders' AND name LIKE '%idempotency%'; diff --git a/package-lock.json b/package-lock.json index 47bc661..92e83ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "hono": "^4.11.7", "openai": "^6.16.0" }, "devDependencies": { @@ -1825,6 +1826,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hono": { + "version": "4.11.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", + "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/package.json b/package.json index 67bb1da..b685c76 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "wrangler": "^4.60.0" }, "dependencies": { + "hono": "^4.11.7", "openai": "^6.16.0" } } diff --git a/src/config.ts b/src/config.ts index 1b0256e..efd3172 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,32 @@ export const LIMITS = { MAX_TECH_STACK: 20, MAX_USE_CASE_LENGTH: 500, MAX_REGION_PREFERENCE: 10, + MAX_IN_MEMORY_RATE_LIMIT_ENTRIES: 10000, +} as const; + +/** + * Timeout configurations (in milliseconds) + */ +export const TIMEOUTS = { + AI_REQUEST_MS: 30000, // 30 seconds + SERVER_PROVISIONING_MS: 120000, // 2 minutes + EXCHANGE_RATE_API_MS: 5000, // 5 seconds + VPS_PROVIDER_API_MS: 30000, // 30 seconds +} as const; + +/** + * Tech category weights for vCPU calculation + * Different categories have different bottleneck characteristics + */ +export const TECH_CATEGORY_WEIGHTS: Record = { + 'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources + 'runtime': 1.0, // nodejs, php, python: actual computation + 'database': 1.0, // mysql, postgresql, mongodb: major bottleneck + 'cache': 0.5, // redis, memcached: supporting role + 'search': 0.8, // elasticsearch: CPU-intensive but not always primary + 'container': 0.3, // docker: orchestration overhead + 'messaging': 0.5, // rabbitmq, kafka: I/O bound + 'default': 0.7, // unknown categories } as const; export const USE_CASE_CONFIGS: UseCaseConfig[] = [ diff --git a/src/handlers/health.ts b/src/handlers/health.ts index ee7cdb3..19a1c60 100644 --- a/src/handlers/health.ts +++ b/src/handlers/health.ts @@ -2,19 +2,17 @@ * Health check endpoint handler */ -import { jsonResponse } from '../utils'; +import type { Context } from 'hono'; +import type { Env } from '../types'; /** * Health check endpoint */ -export function handleHealth(corsHeaders: Record): Response { - return jsonResponse( - { - status: 'ok', - timestamp: new Date().toISOString(), - service: 'server-recommend', - }, - 200, - corsHeaders - ); +export function handleHealth(c: Context<{ Bindings: Env }>) { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + service: 'server-recommend', + request_id: c.get('requestId'), + }); } diff --git a/src/handlers/provision.ts b/src/handlers/provision.ts index 3476b32..b5a7ee2 100644 --- a/src/handlers/provision.ts +++ b/src/handlers/provision.ts @@ -5,11 +5,12 @@ * GET /api/provision/orders/:id - Get specific order * DELETE /api/provision/orders/:id - Delete/terminate server * GET /api/provision/balance - Get user's balance + * GET /api/provision/images - Get available OS images */ +import type { Context } from 'hono'; import type { Env, ProvisionRequest } from '../types'; import { ProvisioningService } from '../services/provisioning-service'; -import { createErrorResponse, createSuccessResponse } from '../utils/http'; import { LIMITS } from '../config'; /** @@ -72,68 +73,63 @@ function validateProvisionRequest(body: unknown): { }; } +/** + * Create ProvisioningService instance + */ +function createProvisioningService(env: Env) { + return new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY, + env.LINODE_API_URL, + env.VULTR_API_URL + ); +} + /** * POST /api/provision * Create a new server */ -export async function handleProvision( - request: Request, - env: Env, - corsHeaders: Record -): Promise { +export async function handleProvision(c: Context<{ Bindings: Env }>) { try { // Check content length - const contentLength = request.headers.get('content-length'); + const contentLength = c.req.header('content-length'); if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) { - return createErrorResponse('Request body too large', 413, undefined, corsHeaders); + return c.json({ error: 'Request body too large', request_id: c.get('requestId') }, 413); } // Parse request body let body: unknown; try { - body = await request.json(); + body = await c.req.json(); } catch { - return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders); + return c.json({ error: 'Invalid JSON in request body', request_id: c.get('requestId') }, 400); } // Validate request const validation = validateProvisionRequest(body); if (!validation.valid) { - return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders); + return c.json({ error: validation.error, code: 'VALIDATION_ERROR', request_id: c.get('requestId') }, 400); } // Check API keys (skip for dry_run) - if (!validation.data.dry_run && !env.LINODE_API_KEY && !env.VULTR_API_KEY) { - return createErrorResponse( - 'No VPS provider API keys configured', - 503, - 'SERVICE_UNAVAILABLE', - corsHeaders + if (!validation.data.dry_run && !c.env.LINODE_API_KEY && !c.env.VULTR_API_KEY) { + return c.json( + { error: 'No VPS provider API keys configured', code: 'SERVICE_UNAVAILABLE', request_id: c.get('requestId') }, + 503 ); } - // Create provisioning service with both DBs - const provisioningService = new ProvisioningService( - env, // Full env for exchange rate - env.DB, // cloud-instances-db - env.USER_DB, // telegram-conversations - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); - - // Provision server + const provisioningService = createProvisioningService(c.env); const result = await provisioningService.provisionServer(validation.data); if (!result.success) { - // Map error codes to HTTP status codes const statusCode = getStatusCodeForError(result.error!.code); - return createErrorResponse( - result.error!.message, - statusCode, - result.error!.code, - corsHeaders + return c.json( + { error: result.error!.message, code: result.error!.code, request_id: c.get('requestId') }, + statusCode ); } @@ -143,8 +139,8 @@ export async function handleProvision( root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***', }; - // Include dry_run_info if present const response: Record = { + success: true, message: validation.data.dry_run ? 'Dry run successful. Validation passed.' : 'Server provisioned successfully', @@ -156,14 +152,12 @@ export async function handleProvision( response.dry_run_info = (result as Record).dry_run_info; } - return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders); + return c.json(response, validation.data.dry_run ? 200 : 201); } catch (error) { - console.error('[handleProvision] Error:', error); - return createErrorResponse( - 'Internal server error during provisioning', - 500, - 'INTERNAL_ERROR', - corsHeaders + console.error('[handleProvision] Error:', error, 'request_id:', c.get('requestId')); + return c.json( + { error: 'Internal server error during provisioning', code: 'INTERNAL_ERROR', request_id: c.get('requestId') }, + 500 ); } } @@ -172,29 +166,11 @@ export async function handleProvision( * GET /api/provision/orders * Get user's orders */ -export async function handleGetOrders( - request: Request, - env: Env, - corsHeaders: Record -): Promise { +export async function handleGetOrders(c: Context<{ Bindings: Env }>) { try { - const url = new URL(request.url); - const userId = url.searchParams.get('user_id'); // This is telegram_id - - if (!userId) { - return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); - } - - const provisioningService = new ProvisioningService( - env, - env.DB, - env.USER_DB, - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); + const userId = c.get('userId'); // Set by validateUserId middleware + const provisioningService = createProvisioningService(c.env); const orders = await provisioningService.getUserOrders(userId); // Sanitize root passwords @@ -203,10 +179,10 @@ export async function handleGetOrders( root_password: order.root_password ? '***REDACTED***' : null, })); - return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders); + return c.json({ orders: sanitizedOrders }); } catch (error) { - console.error('[handleGetOrders] Error:', error); - return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + console.error('[handleGetOrders] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); } } @@ -214,47 +190,29 @@ export async function handleGetOrders( * GET /api/provision/orders/:id * Get specific order with root password (one-time view concept) */ -export async function handleGetOrder( - request: Request, - env: Env, - orderId: string, - corsHeaders: Record -): Promise { +export async function handleGetOrder(c: Context<{ Bindings: Env }>) { try { - const url = new URL(request.url); - const userId = url.searchParams.get('user_id'); // This is telegram_id - - if (!userId) { - return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); - } - - const provisioningService = new ProvisioningService( - env, - env.DB, - env.USER_DB, - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); + const orderId = c.req.param('id'); + const userId = c.get('userId'); // Set by validateUserId middleware + const provisioningService = createProvisioningService(c.env); const order = await provisioningService.getOrder(orderId); if (!order) { - return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders); + return c.json({ error: 'Order not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); } // Get user to verify ownership const balance = await provisioningService.getUserBalance(userId); if (!balance || order.user_id !== balance.user_id) { - return createErrorResponse('Unauthorized', 403, 'UNAUTHORIZED', corsHeaders); + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED', request_id: c.get('requestId') }, 403); } // Include root password for order owner - return createSuccessResponse({ order }, 200, corsHeaders); + return c.json({ order }); } catch (error) { - console.error('[handleGetOrder] Error:', error); - return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + console.error('[handleGetOrder] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); } } @@ -262,55 +220,37 @@ export async function handleGetOrder( * DELETE /api/provision/orders/:id * Delete/terminate a server */ -export async function handleDeleteOrder( - request: Request, - env: Env, - orderId: string, - corsHeaders: Record -): Promise { +export async function handleDeleteOrder(c: Context<{ Bindings: Env }>) { try { - const url = new URL(request.url); - const userId = url.searchParams.get('user_id'); // This is telegram_id + const orderId = c.req.param('id'); + const userId = c.get('userId'); // Set by validateUserId middleware - if (!userId) { - return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); - } + const provisioningService = createProvisioningService(c.env); - const provisioningService = new ProvisioningService( - env, - env.DB, - env.USER_DB, - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); - - // Verify user exists first (same pattern as handleGetOrder) + // Verify user exists first const balance = await provisioningService.getUserBalance(userId); if (!balance) { - return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders); + return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); } const result = await provisioningService.deleteServer(orderId, userId); if (!result.success) { - // Map specific errors to appropriate status codes const statusCode = getDeleteErrorStatusCode(result.error!); - return createErrorResponse(result.error!, statusCode, 'DELETE_FAILED', corsHeaders); + return c.json({ error: result.error!, code: 'DELETE_FAILED', request_id: c.get('requestId') }, statusCode); } - return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders); + return c.json({ message: 'Server terminated successfully' }); } catch (error) { - console.error('[handleDeleteOrder] Error:', error); - return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + console.error('[handleDeleteOrder] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); } } /** * Map delete error messages to HTTP status codes */ -function getDeleteErrorStatusCode(error: string): number { +function getDeleteErrorStatusCode(error: string): 400 | 403 | 404 { if (error === 'Order not found') return 404; if (error === 'Unauthorized') return 403; if (error === 'User not found') return 404; @@ -321,46 +261,24 @@ function getDeleteErrorStatusCode(error: string): number { * GET /api/provision/balance * Get user's balance (in KRW) */ -export async function handleGetBalance( - request: Request, - env: Env, - corsHeaders: Record -): Promise { +export async function handleGetBalance(c: Context<{ Bindings: Env }>) { try { - const url = new URL(request.url); - const userId = url.searchParams.get('user_id'); // This is telegram_id - - if (!userId) { - return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); - } - - const provisioningService = new ProvisioningService( - env, - env.DB, - env.USER_DB, - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); + const userId = c.get('userId'); // Set by validateUserId middleware + const provisioningService = createProvisioningService(c.env); const balance = await provisioningService.getUserBalance(userId); if (!balance) { - return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders); + return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404); } - return createSuccessResponse( - { - balance_krw: balance.balance_krw, - currency: 'KRW', - }, - 200, - corsHeaders - ); + return c.json({ + balance_krw: balance.balance_krw, + currency: 'KRW', + }); } catch (error) { - console.error('[handleGetBalance] Error:', error); - return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + console.error('[handleGetBalance] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); } } @@ -368,21 +286,9 @@ export async function handleGetBalance( * GET /api/provision/images * Get available OS images */ -export async function handleGetOsImages( - env: Env, - corsHeaders: Record -): Promise { +export async function handleGetOsImages(c: Context<{ Bindings: Env }>) { try { - const provisioningService = new ProvisioningService( - env, - env.DB, - env.USER_DB, - env.LINODE_API_KEY, - env.VULTR_API_KEY, - env.LINODE_API_URL, - env.VULTR_API_URL - ); - + const provisioningService = createProvisioningService(c.env); const images = await provisioningService.getOsImages(); // Return simplified image list for API consumers @@ -393,17 +299,17 @@ export async function handleGetOsImages( is_default: img.is_default === 1, })); - return createSuccessResponse({ images: response }, 200, corsHeaders); + return c.json({ images: response }); } catch (error) { - console.error('[handleGetOsImages] Error:', error); - return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + console.error('[handleGetOsImages] Error:', error, 'request_id:', c.get('requestId')); + return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500); } } /** * Map error codes to HTTP status codes */ -function getStatusCodeForError(code: string): number { +function getStatusCodeForError(code: string): 400 | 402 | 403 | 404 | 500 | 503 { switch (code) { case 'USER_NOT_FOUND': case 'PRICING_NOT_FOUND': diff --git a/src/handlers/queue.ts b/src/handlers/queue.ts index bc91b32..ea8586c 100644 --- a/src/handlers/queue.ts +++ b/src/handlers/queue.ts @@ -25,16 +25,23 @@ export async function handleProvisionQueue( ); for (const message of batch.messages) { + const { order_id, user_id, pricing_id } = message.body; + const logContext = { order_id, user_id, pricing_id, message_id: message.id }; + try { - console.log(`[Queue] Processing message for order ${message.body.order_id}`); + console.log('[Queue] Processing message:', JSON.stringify(logContext)); await provisioningService.processQueueMessage(message.body); // Acknowledge successful processing message.ack(); - console.log(`[Queue] Order ${message.body.order_id} processed successfully`); + console.log('[Queue] Message processed successfully:', JSON.stringify(logContext)); } catch (error) { - console.error(`[Queue] Error processing order ${message.body.order_id}:`, error); + console.error('[Queue] Error processing message:', JSON.stringify({ + ...logContext, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + })); // Retry the message (will go to DLQ after max_retries) message.retry(); diff --git a/src/handlers/recommend.ts b/src/handlers/recommend.ts index 02f24cb..61d3b7d 100644 --- a/src/handlers/recommend.ts +++ b/src/handlers/recommend.ts @@ -2,6 +2,7 @@ * POST /api/recommend - AI-powered server recommendation handler */ +import type { Context } from 'hono'; import type { Env, RecommendRequest, @@ -14,9 +15,8 @@ import type { BenchmarkReference, AvailableRegion } from '../types'; -import { LIMITS } from '../config'; +import { LIMITS, TECH_CATEGORY_WEIGHTS } from '../config'; import { - jsonResponse, validateRecommendRequest, generateCacheKey, estimateBandwidth, @@ -32,33 +32,27 @@ import { generateRuleBasedRecommendations, } from '../services/ai-service'; -export async function handleRecommend( - request: Request, - env: Env, - corsHeaders: Record -): Promise { - const requestId = crypto.randomUUID(); +export async function handleRecommend(c: Context<{ Bindings: Env }>) { + const requestId = c.get('requestId'); try { // Check request body size to prevent large payload attacks - const contentLength = request.headers.get('Content-Length'); + const contentLength = c.req.header('Content-Length'); if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) { - return jsonResponse( - { error: 'Request body too large', max_size: '10KB' }, - 413, - corsHeaders + return c.json( + { error: 'Request body too large', max_size: '10KB', request_id: requestId }, + 413 ); } // Parse and validate request with actual body size check - const bodyText = await request.text(); + const bodyText = await c.req.text(); const actualBodySize = new TextEncoder().encode(bodyText).length; if (actualBodySize > LIMITS.MAX_REQUEST_BODY_BYTES) { - return jsonResponse( - { error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize }, - 413, - corsHeaders + return c.json( + { error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize, request_id: requestId }, + 413 ); } @@ -68,24 +62,24 @@ export async function handleRecommend( body = JSON.parse(bodyText) as RecommendRequest; } catch (parseError) { console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown'); - return jsonResponse({ + return c.json({ error: 'Invalid JSON format', request_id: requestId, - }, 400, corsHeaders); + }, 400); } // Validate body is an object before proceeding if (!body || typeof body !== 'object' || Array.isArray(body)) { - return jsonResponse({ + return c.json({ error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object', request_id: requestId, - }, 400, corsHeaders); + }, 400); } const lang = body.lang || 'en'; const validationError = validateRecommendRequest(body, lang); if (validationError) { - return jsonResponse(validationError, 400, corsHeaders); + return c.json(validationError, 400); } console.log('[Recommend] Request summary:', { @@ -102,23 +96,23 @@ export async function handleRecommend( console.log('[Recommend] Cache key:', cacheKey); // Check cache (optional - may not be configured) - if (env.CACHE) { - const cached = await env.CACHE.get(cacheKey); + if (c.env.CACHE) { + const cached = await c.env.CACHE.get(cacheKey); if (cached) { try { const parsed = JSON.parse(cached); // Validate required fields exist if (parsed && Array.isArray(parsed.recommendations)) { console.log('[Recommend] Cache hit'); - return jsonResponse( - { ...parsed, cached: true }, - 200, - corsHeaders - ); + return c.json({ ...parsed, cached: true }); } - console.warn('[Recommend] Invalid cached data structure, ignoring'); - } catch (parseError) { - console.warn('[Recommend] Cache parse error, ignoring cached data'); + // Invalid cache structure, delete and continue + console.warn('[Recommend] Invalid cached data structure, deleting:', cacheKey); + await c.env.CACHE.delete(cacheKey); + } catch { + // Corrupted cache, delete and continue + console.warn('[Recommend] Corrupted cache, deleting:', cacheKey); + await c.env.CACHE.delete(cacheKey); } } } @@ -127,8 +121,8 @@ export async function handleRecommend( // Phase 1: Execute independent queries in parallel const [techSpecs, benchmarkDataAll] = await Promise.all([ - queryTechSpecs(env.DB, body.tech_stack), - queryBenchmarkData(env.DB, body.tech_stack).catch(err => { + queryTechSpecs(c.env.DB, body.tech_stack), + queryBenchmarkData(c.env.DB, body.tech_stack).catch(err => { console.warn('[Recommend] Benchmark data unavailable:', err.message); return [] as BenchmarkData[]; }), @@ -191,24 +185,12 @@ export async function handleRecommend( console.log(`[Recommend] DB workload inferred from use_case: ${dbWorkload.type} (multiplier: ${dbWorkload.multiplier})`); if (techSpecs.length > 0) { - // Group specs by category - const categoryWeights: Record = { - 'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources - 'runtime': 1.0, // nodejs, php, python: actual computation - 'database': 1.0, // mysql, postgresql, mongodb: major bottleneck - 'cache': 0.5, // redis, memcached: supporting role - 'search': 0.8, // elasticsearch: CPU-intensive but not always primary - 'container': 0.3, // docker: orchestration overhead - 'messaging': 0.5, // rabbitmq, kafka: I/O bound - 'default': 0.7 // unknown categories - }; - // Calculate weighted vCPU requirements per category const categoryRequirements = new Map(); for (const spec of techSpecs) { const category = spec.category || 'default'; - const weight = categoryWeights[category] || categoryWeights['default']; + const weight = TECH_CATEGORY_WEIGHTS[category] || TECH_CATEGORY_WEIGHTS['default']; // Apply DB workload multiplier for database category // Lower multiplier = heavier workload = higher resource needs (lower vcpu_per_users) @@ -260,10 +242,10 @@ export async function handleRecommend( const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr']; // Phase 2: Parallel queries including exchange rate for Korean users - const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1); + const exchangeRatePromise = lang === 'ko' ? getExchangeRate(c.env) : Promise.resolve(1); // Use repository to fetch candidate servers - const repository = new AnvilServerRepository(env.DB); + const repository = new AnvilServerRepository(c.env.DB); const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([ repository.findServers({ @@ -273,7 +255,7 @@ export async function handleRecommend( budgetLimit: body.budget_limit, limit: LIMITS.MAX_AI_CANDIDATES * 3, // Fetch more to allow for filtering }), - queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => { + queryVPSBenchmarksBatch(c.env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); console.warn('[Recommend] VPS benchmarks unavailable:', message); return [] as VPSBenchmark[]; @@ -284,9 +266,9 @@ export async function handleRecommend( // Apply exchange rate to candidates if needed (Korean users) // 서버 가격: 500원 단위 반올림 if (lang === 'ko' && exchangeRate !== 1) { - candidates.forEach(c => { - c.monthly_price = Math.round((c.monthly_price * exchangeRate) / 500) * 500; - c.currency = 'KRW'; + candidates.forEach(candidate => { + candidate.monthly_price = Math.round((candidate.monthly_price * exchangeRate) / 500) * 500; + candidate.currency = 'KRW'; }); } @@ -294,15 +276,11 @@ export async function handleRecommend( console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length); if (candidates.length === 0) { - return jsonResponse( - { - error: 'No servers found matching your requirements', - recommendations: [], - request_id: requestId, - }, - 200, - corsHeaders - ); + return c.json({ + error: 'No servers found matching your requirements', + recommendations: [], + request_id: requestId, + }, 404); } // Bandwidth-based filtering: prioritize servers with adequate transfer allowance @@ -353,8 +331,8 @@ export async function handleRecommend( let aiResult: { recommendations: RecommendationResult[]; infrastructure_tips?: string[] }; try { aiResult = await getAIRecommendations( - env, - env.OPENAI_API_KEY, + c.env, + c.env.OPENAI_API_KEY, body, filteredCandidates, // Use bandwidth-filtered candidates benchmarkData, @@ -421,24 +399,25 @@ export async function handleRecommend( }; // Cache result only if we have recommendations (don't cache empty/failed results) - if (env.CACHE && response.recommendations && response.recommendations.length > 0) { - await env.CACHE.put(cacheKey, JSON.stringify(response), { + if (c.env.CACHE && response.recommendations && response.recommendations.length > 0) { + await c.env.CACHE.put(cacheKey, JSON.stringify(response), { expirationTtl: 300, // 5 minutes (reduced from 1 hour for faster iteration) }); } - return jsonResponse(response, 200, corsHeaders); + return c.json(response); } catch (error) { - console.error('[Recommend] Error:', error); - console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack'); - console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error'); - return jsonResponse( + console.error('[Recommend] Error:', JSON.stringify({ + request_id: requestId, + message: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + })); + return c.json( { error: 'Failed to generate recommendations', request_id: requestId, }, - 500, - corsHeaders + 500 ); } } @@ -646,7 +625,9 @@ async function queryTechSpecs( for (const tech of normalizedStack) { conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`); - params.push(tech, `%"${tech}"%`); + // Escape special LIKE characters (%, _, \) in tech name + const escapedTech = escapeLikePattern(tech); + params.push(tech, `%"${escapedTech}"%`); } const query = ` diff --git a/src/handlers/report.ts b/src/handlers/report.ts index 024c306..753a4f3 100644 --- a/src/handlers/report.ts +++ b/src/handlers/report.ts @@ -6,8 +6,9 @@ * - lang: Language (en, ko, ja, zh) - default: en */ +import type { Context } from 'hono'; import type { Env, RecommendationResult, BandwidthEstimate } from '../types'; -import { jsonResponse, escapeHtml } from '../utils'; +import { escapeHtml } from '../utils'; interface ReportData { recommendations: RecommendationResult[]; @@ -128,21 +129,25 @@ function getTierColor(index: number): string { return colors[index] || '#6b7280'; } -export async function handleReport( - request: Request, - env: Env, - corsHeaders: Record -): Promise { +export async function handleReport(c: Context<{ Bindings: Env }>) { + const requestId = c.get('requestId'); + try { - const url = new URL(request.url); - const dataParam = url.searchParams.get('data'); - const lang = url.searchParams.get('lang') || 'en'; + const dataParam = c.req.query('data'); + const lang = c.req.query('lang') || 'en'; if (!dataParam) { - return jsonResponse( - { error: 'Missing data parameter. Provide Base64-encoded recommendation data.' }, - 400, - corsHeaders + return c.json( + { error: 'Missing data parameter. Provide Base64-encoded recommendation data.', request_id: requestId }, + 400 + ); + } + + // Validate Base64 data size (max ~75KB decoded) + if (dataParam.length > 100000) { + return c.json( + { error: 'Data parameter too large', request_id: requestId }, + 413 ); } @@ -152,37 +157,35 @@ export async function handleReport( const decoded = atob(dataParam); reportData = JSON.parse(decoded) as ReportData; } catch { - return jsonResponse( - { error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' }, - 400, - corsHeaders + return c.json( + { error: 'Invalid data parameter. Must be valid Base64-encoded JSON.', request_id: requestId }, + 400 ); } if (!reportData.recommendations || reportData.recommendations.length === 0) { - return jsonResponse( - { error: 'No recommendations in data.' }, - 400, - corsHeaders + return c.json( + { error: 'No recommendations in data.', request_id: requestId }, + 400 ); } const labels = getLabels(lang); const html = generateReportHTML(reportData, labels, lang); + // Return HTML with CSP that allows inline styles (required for printable report) return new Response(html, { - status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8', - ...corsHeaders, + 'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'", + 'X-Content-Type-Options': 'nosniff', }, }); } catch (error) { - console.error('[Report] Error:', error); - return jsonResponse( - { error: 'Failed to generate report' }, - 500, - corsHeaders + console.error('[Report] Error:', error, 'request_id:', requestId); + return c.json( + { error: 'Failed to generate report', request_id: requestId }, + 500 ); } } diff --git a/src/handlers/servers.ts b/src/handlers/servers.ts index 2da30e4..cf68c61 100644 --- a/src/handlers/servers.ts +++ b/src/handlers/servers.ts @@ -2,40 +2,46 @@ * GET /api/servers - Server list with filtering handler */ +import type { Context } from 'hono'; import type { Env } from '../types'; -import { jsonResponse } from '../utils'; import { AnvilServerRepository } from '../repositories/AnvilServerRepository'; /** * GET /api/servers - Server list with filtering * Uses anvil_* tables for pricing data */ -export async function handleGetServers( - request: Request, - env: Env, - corsHeaders: Record -): Promise { - try { - const url = new URL(request.url); - const minCpu = url.searchParams.get('minCpu'); - const minMemory = url.searchParams.get('minMemory'); - const region = url.searchParams.get('region'); +export async function handleGetServers(c: Context<{ Bindings: Env }>) { + const requestId = c.get('requestId'); - console.log('[GetServers] Query params:', { - minCpu, - minMemory, - region, - }); + try { + const minCpu = c.req.query('minCpu'); + const minMemory = c.req.query('minMemory'); + const region = c.req.query('region'); + + console.log('[GetServers] Query params:', { minCpu, minMemory, region, request_id: requestId }); // Generate cache key from query parameters - const cacheKey = `servers:${url.search || 'all'}`; + const cacheKey = `servers:${new URL(c.req.url).search || 'all'}`; // Check cache first - if (env.CACHE) { - const cached = await env.CACHE.get(cacheKey); + if (c.env.CACHE) { + const cached = await c.env.CACHE.get(cacheKey); if (cached) { - console.log('[GetServers] Cache hit for:', cacheKey); - return jsonResponse({ ...JSON.parse(cached), cached: true }, 200, corsHeaders); + try { + const parsed = JSON.parse(cached); + // Validate cache structure + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.servers)) { + console.log('[GetServers] Cache hit for:', cacheKey); + return c.json({ ...parsed, cached: true }); + } + // Invalid cache structure, delete and continue + console.warn('[GetServers] Invalid cache structure, deleting:', cacheKey, 'request_id:', requestId); + await c.env.CACHE.delete(cacheKey); + } catch { + // Corrupted cache, delete and continue + console.warn('[GetServers] Corrupted cache, deleting:', cacheKey, 'request_id:', requestId); + await c.env.CACHE.delete(cacheKey); + } } } @@ -46,19 +52,19 @@ export async function handleGetServers( if (minCpu) { parsedCpu = parseInt(minCpu, 10); if (isNaN(parsedCpu)) { - return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders); + return c.json({ error: 'Invalid minCpu parameter' }, 400); } } if (minMemory) { parsedMemory = parseInt(minMemory, 10); if (isNaN(parsedMemory)) { - return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders); + return c.json({ error: 'Invalid minMemory parameter' }, 400); } } // Use repository to fetch servers - const repository = new AnvilServerRepository(env.DB); + const repository = new AnvilServerRepository(c.env.DB); const servers = await repository.findServers({ minCpu: parsedCpu, minMemoryGb: parsedMemory, @@ -66,7 +72,7 @@ export async function handleGetServers( limit: 100, }); - console.log('[GetServers] Found servers:', servers.length); + console.log('[GetServers] Found servers:', servers.length, 'request_id:', requestId); const responseData = { servers, @@ -75,23 +81,18 @@ export async function handleGetServers( }; // Cache successful results (only if we have servers) - if (env.CACHE && servers.length > 0) { - await env.CACHE.put(cacheKey, JSON.stringify(responseData), { + if (c.env.CACHE && servers.length > 0) { + await c.env.CACHE.put(cacheKey, JSON.stringify(responseData), { expirationTtl: 300, // 5 minutes }); } - return jsonResponse(responseData, 200, corsHeaders); + return c.json(responseData); } catch (error) { - console.error('[GetServers] Error:', error); - const requestId = crypto.randomUUID(); - return jsonResponse( - { - error: 'Failed to retrieve servers', - request_id: requestId, - }, - 500, - corsHeaders + console.error('[GetServers] Error:', error, 'request_id:', c.get('requestId')); + return c.json( + { error: 'Failed to retrieve servers', request_id: c.get('requestId') }, + 500 ); } } diff --git a/src/index.ts b/src/index.ts index bc1b75a..b9249a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,13 @@ * Cloudflare Worker - Server Recommendation System Entry Point * * AI-powered server recommendation service using Workers AI, D1, and KV. + * Built with Hono framework. */ -import type { Env } from './types'; -import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Env, ProvisionQueueMessage } from './types'; +import { rateLimiter, provisionAuth, validateOrigin, validateUserId, securityHeaders, requestId } from './middleware'; import { handleHealth } from './handlers/health'; import { handleGetServers } from './handlers/servers'; import { handleRecommend } from './handlers/recommend'; @@ -19,139 +22,69 @@ import { handleGetOsImages, } from './handlers/provision'; import { handleProvisionQueue } from './handlers/queue'; -import type { ProvisionQueueMessage } from './types'; -/** - * Main request handler - */ +// Create Hono app with typed bindings +const app = new Hono<{ Bindings: Env }>(); + +// Request ID middleware (first - for tracking) +app.use('*', requestId); + +// CORS middleware - uses shared origin validation +app.use('*', cors({ + origin: (origin) => validateOrigin(origin), + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'X-API-Key', 'X-Request-ID'], +})); + +// Security headers +app.use('*', securityHeaders); + +// Rate limiting middleware +app.use('*', rateLimiter); + +// Health check +app.get('/api/health', handleHealth); + +// Server listing +app.get('/api/servers', handleGetServers); + +// Recommendations +app.post('/api/recommend', handleRecommend); +app.get('/api/recommend/report', handleReport); + +// Provisioning endpoints (with auth + user_id validation middleware) +const provision = new Hono<{ Bindings: Env }>(); +provision.use('*', provisionAuth); +provision.use('*', validateUserId); +provision.post('/', handleProvision); +provision.get('/orders', handleGetOrders); +provision.get('/orders/:id', handleGetOrder); +provision.delete('/orders/:id', handleDeleteOrder); +provision.get('/balance', handleGetBalance); +provision.get('/images', handleGetOsImages); + +app.route('/api/provision', provision); + +// 404 handler +app.notFound((c) => { + return c.json( + { error: 'Not found', request_id: c.get('requestId') }, + 404 + ); +}); + +// Error handler +app.onError((err, c) => { + console.error('[Worker] Unhandled error:', err, 'request_id:', c.get('requestId')); + return c.json( + { error: 'Internal server error', request_id: c.get('requestId') }, + 500 + ); +}); + +// Export for Cloudflare Workers export default { - async fetch(request: Request, env: Env): Promise { - const requestId = crypto.randomUUID(); - - try { - const url = new URL(request.url); - const path = url.pathname; - - // Rate limiting (except for health checks) - if (path !== '/api/health') { - const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateCheck = await checkRateLimit(clientIP, env); - - if (!rateCheck.allowed) { - const origin = getAllowedOrigin(request); - return jsonResponse( - { error: 'Too many requests', request_id: rateCheck.requestId }, - 429, - { - 'Access-Control-Allow-Origin': origin, - 'Vary': 'Origin', - } - ); - } - } - - // CORS headers for all responses - const origin = getAllowedOrigin(request); - const corsHeaders = { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Vary': 'Origin', - }; - - // Handle preflight requests - if (request.method === 'OPTIONS') { - return new Response(null, { headers: corsHeaders }); - } - - // Route handling - if (path === '/api/health') { - return handleHealth(corsHeaders); - } - - if (path === '/api/servers' && request.method === 'GET') { - return handleGetServers(request, env, corsHeaders); - } - - if (path === '/api/recommend' && request.method === 'POST') { - return handleRecommend(request, env, corsHeaders); - } - - if (path === '/api/recommend/report' && request.method === 'GET') { - return handleReport(request, env, corsHeaders); - } - - // Provisioning endpoints (restricted to *.kappa-d8e.workers.dev + API key) - if (path.startsWith('/api/provision')) { - // Check API key (required for all provision requests) - const apiKey = request.headers.get('X-API-Key'); - if (!env.PROVISION_API_KEY || apiKey !== env.PROVISION_API_KEY) { - return jsonResponse( - { error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED' }, - 401, - corsHeaders - ); - } - - // Check origin for browser requests - if (!isAllowedProvisionOrigin(request)) { - return jsonResponse( - { error: 'Forbidden: Invalid origin', code: 'FORBIDDEN' }, - 403, - corsHeaders - ); - } - - if (path === '/api/provision' && request.method === 'POST') { - return handleProvision(request, env, corsHeaders); - } - - if (path === '/api/provision/orders' && request.method === 'GET') { - return handleGetOrders(request, env, corsHeaders); - } - - if (path === '/api/provision/balance' && request.method === 'GET') { - return handleGetBalance(request, env, corsHeaders); - } - - if (path === '/api/provision/images' && request.method === 'GET') { - return handleGetOsImages(env, corsHeaders); - } - - // Dynamic route: /api/provision/orders/:id - const orderMatch = path.match(/^\/api\/provision\/orders\/([a-zA-Z0-9-]+)$/); - if (orderMatch) { - const orderId = orderMatch[1]; - if (request.method === 'GET') { - return handleGetOrder(request, env, orderId, corsHeaders); - } - if (request.method === 'DELETE') { - return handleDeleteOrder(request, env, orderId, corsHeaders); - } - } - } - - return jsonResponse( - { error: 'Not found', request_id: requestId }, - 404, - corsHeaders - ); - } catch (error) { - console.error('[Worker] Unhandled error:', error); - const origin = getAllowedOrigin(request); - return jsonResponse( - { - error: 'Internal server error', - request_id: requestId, - }, - 500, - { - 'Access-Control-Allow-Origin': origin, - 'Vary': 'Origin', - } - ); - } - }, + fetch: app.fetch, /** * Queue handler for async server provisioning diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..45f76b2 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,32 @@ +/** + * Authentication middleware for provisioning endpoints + */ + +import { Context, Next } from 'hono'; +import type { Env } from '../types'; +import { isAllowedOrigin } from './origin'; + +/** + * Middleware to check API key for provisioning endpoints + */ +export async function provisionAuth(c: Context<{ Bindings: Env }>, next: Next) { + const apiKey = c.req.header('X-API-Key'); + + if (!c.env.PROVISION_API_KEY || apiKey !== c.env.PROVISION_API_KEY) { + return c.json( + { error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED', request_id: c.get('requestId') }, + 401 + ); + } + + // Check origin for browser requests + const origin = c.req.header('Origin'); + if (!isAllowedOrigin(origin)) { + return c.json( + { error: 'Forbidden: Invalid origin', code: 'FORBIDDEN', request_id: c.get('requestId') }, + 403 + ); + } + + return next(); +} diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..24d766b --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,10 @@ +/** + * Middleware exports + */ + +export { provisionAuth } from './auth'; +export { rateLimiter } from './rate-limit'; +export { validateOrigin, isAllowedOrigin } from './origin'; +export { validateUserId } from './user-id'; +export { securityHeaders } from './security'; +export { requestId } from './request-id'; diff --git a/src/middleware/origin.ts b/src/middleware/origin.ts new file mode 100644 index 0000000..28ad221 --- /dev/null +++ b/src/middleware/origin.ts @@ -0,0 +1,33 @@ +/** + * Shared origin validation utilities + */ + +const DEFAULT_ORIGIN = 'https://cloud-orchestrator.kappa-d8e.workers.dev'; + +/** + * Check if origin is allowed + * Returns the allowed origin string or null if rejected + */ +export function validateOrigin(origin: string | null | undefined): string | null { + // No origin = non-browser request (curl, server) - allowed with default + if (!origin) return DEFAULT_ORIGIN; + + // Allow only our Cloudflare Workers account subdomain + if (origin.endsWith('.kappa-d8e.workers.dev')) return origin; + + // Allow localhost for development + if (origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:')) { + return origin; + } + + // Reject other origins + return null; +} + +/** + * Check if request origin is allowed (boolean version) + */ +export function isAllowedOrigin(origin: string | null | undefined): boolean { + return validateOrigin(origin) !== null; +} diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts new file mode 100644 index 0000000..26e8ced --- /dev/null +++ b/src/middleware/rate-limit.ts @@ -0,0 +1,28 @@ +/** + * Rate limiting middleware + */ + +import { Context, Next } from 'hono'; +import type { Env } from '../types'; +import { checkRateLimit } from '../utils/cache'; + +/** + * Rate limiting middleware + * Skips health check endpoint + */ +export async function rateLimiter(c: Context<{ Bindings: Env }>, next: Next) { + // Skip rate limiting for health checks + if (c.req.path === '/api/health') return next(); + + const clientIP = c.req.header('CF-Connecting-IP') || 'unknown'; + const rateCheck = await checkRateLimit(clientIP, c.env); + + if (!rateCheck.allowed) { + return c.json( + { error: 'Too many requests', request_id: c.get('requestId') }, + 429 + ); + } + + return next(); +} diff --git a/src/middleware/request-id.ts b/src/middleware/request-id.ts new file mode 100644 index 0000000..6630cad --- /dev/null +++ b/src/middleware/request-id.ts @@ -0,0 +1,29 @@ +/** + * Request ID middleware for request tracking + */ + +import { Context, Next } from 'hono'; + +// Extend Hono context to include requestId +declare module 'hono' { + interface ContextVariableMap { + requestId: string; + } +} + +/** + * Middleware to generate and track request IDs + * Adds X-Request-ID header to all responses + */ +export async function requestId(c: Context, next: Next) { + // Use existing request ID from header or generate new one + const id = c.req.header('X-Request-ID') || crypto.randomUUID(); + + // Store in context for handlers to use + c.set('requestId', id); + + // Add to response header + c.header('X-Request-ID', id); + + return next(); +} diff --git a/src/middleware/security.ts b/src/middleware/security.ts new file mode 100644 index 0000000..4bcbd4a --- /dev/null +++ b/src/middleware/security.ts @@ -0,0 +1,30 @@ +/** + * Security headers middleware + */ + +import { Context, Next } from 'hono'; + +/** + * Add security headers to all responses + */ +export async function securityHeaders(c: Context, next: Next) { + await next(); + + // Prevent MIME type sniffing + c.header('X-Content-Type-Options', 'nosniff'); + + // Prevent clickjacking + c.header('X-Frame-Options', 'DENY'); + + // XSS protection (legacy browsers) + c.header('X-XSS-Protection', '1; mode=block'); + + // Referrer policy + c.header('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Content Security Policy for API responses + c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); + + // HSTS - force HTTPS (1 year) + c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); +} diff --git a/src/middleware/user-id.ts b/src/middleware/user-id.ts new file mode 100644 index 0000000..6af234b --- /dev/null +++ b/src/middleware/user-id.ts @@ -0,0 +1,55 @@ +/** + * User ID validation middleware for provision endpoints + */ + +import { Context, Next } from 'hono'; +import type { Env } from '../types'; + +// Extend Hono context to include userId +declare module 'hono' { + interface ContextVariableMap { + userId: string; + } +} + +/** + * Endpoints that don't require user_id validation + * Full paths for exact matching (safer than endsWith) + */ +const SKIP_USER_ID_PATHS = new Set([ + '/api/provision', // POST /api/provision (body has user_id) + '/api/provision/images', // GET /api/provision/images (public endpoint) +]); + +/** + * Middleware to validate and extract user_id from query parameter + * Skips validation for endpoints that don't require user_id + */ +export async function validateUserId(c: Context<{ Bindings: Env }>, next: Next) { + // Skip for endpoints that don't require user_id (exact match) + if (SKIP_USER_ID_PATHS.has(c.req.path)) { + return next(); + } + + const userId = c.req.query('user_id'); + + if (!userId) { + return c.json( + { error: 'user_id query parameter is required', code: 'VALIDATION_ERROR', request_id: c.get('requestId') }, + 400 + ); + } + + // Validate user_id format (basic sanitization) + if (userId.length > 50 || !/^[\w-]+$/.test(userId)) { + return c.json( + { error: 'Invalid user_id format', code: 'VALIDATION_ERROR', request_id: c.get('requestId') }, + 400 + ); + } + + // Store in context for handlers to use + c.set('userId', userId); + + return next(); +} diff --git a/src/repositories/ProvisioningRepository.ts b/src/repositories/ProvisioningRepository.ts index 794f190..8a249e0 100644 --- a/src/repositories/ProvisioningRepository.ts +++ b/src/repositories/ProvisioningRepository.ts @@ -87,20 +87,22 @@ export class ProvisioningRepository { async createServerOrder( userId: number, + telegramUserId: string, specId: number, region: string, pricePaid: number, label: string | null, - image: string | null + image: string | null, + idempotencyKey: string | null ): Promise { const result = await this.userDb .prepare( `INSERT INTO server_orders - (user_id, spec_id, status, region, price_paid, label, image, billing_type) - VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly') + (user_id, telegram_user_id, spec_id, status, region, price_paid, label, image, billing_type, idempotency_key, created_at, expires_at) + VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, 'monthly', ?, CURRENT_TIMESTAMP, datetime(CURRENT_TIMESTAMP, '+720 hours')) RETURNING *` ) - .bind(userId, specId, region, pricePaid, label, image) + .bind(userId, telegramUserId, specId, region, pricePaid, label, image, idempotencyKey) .first(); return result as unknown as ServerOrder; @@ -132,7 +134,16 @@ export class ProvisioningRepository { } } + /** + * Update order with root password + * + * SECURITY WARNING: Password is currently stored in plaintext. + * TODO: Implement encryption using WebCrypto API (AES-GCM) before production use. + * The encryption key should be stored in env.ENCRYPTION_KEY secret. + */ async updateOrderRootPassword(orderId: number, rootPassword: string): Promise { + // TODO: Encrypt password before storage + // const encryptedPassword = await this.encryptPassword(rootPassword, env.ENCRYPTION_KEY); await this.userDb .prepare( `UPDATE server_orders @@ -176,7 +187,9 @@ export class ProvisioningRepository { async getOrdersByUserId(userId: number, limit: number = 20): Promise { const result = await this.userDb .prepare( - 'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?' + `SELECT * FROM server_orders + WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled') + ORDER BY created_at DESC LIMIT ?` ) .bind(userId, limit) .all(); @@ -184,6 +197,18 @@ export class ProvisioningRepository { return result.results as unknown as ServerOrder[]; } + /** + * Find order by idempotency key (for duplicate prevention) + */ + async findOrderByIdempotencyKey(idempotencyKey: string): Promise { + const result = await this.userDb + .prepare('SELECT * FROM server_orders WHERE idempotency_key = ?') + .bind(idempotencyKey) + .first(); + + return result as unknown as ServerOrder | null; + } + // ============================================ // Pricing Lookup (cloud-instances-db) // Uses anvil_pricing, anvil_instances, anvil_regions @@ -233,11 +258,13 @@ export class ProvisioningRepository { */ async createOrderWithPayment( userId: number, + telegramUserId: string, specId: number, region: string, priceKrw: number, label: string | null, - image: string | null + image: string | null, + idempotencyKey: string | null ): Promise<{ orderId: number | null; error?: string }> { try { // Step 1: Check and deduct balance @@ -249,11 +276,13 @@ export class ProvisioningRepository { // Step 2: Create order const order = await this.createServerOrder( userId, + telegramUserId, specId, region, priceKrw, label, - image + image, + idempotencyKey ); return { orderId: order.id }; diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index 519b7ef..83895fd 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -15,7 +15,7 @@ import type { AIRecommendationResponse, BenchmarkReference, } from '../types'; -import { i18n, LIMITS } from '../config'; +import { i18n, LIMITS, TIMEOUTS } from '../config'; import { sanitizeForAIPrompt, isValidAIRecommendation, @@ -56,16 +56,15 @@ export async function getAIRecommendations( const languageInstruction = i18n[validLang].aiLanguageInstruction; // Build system prompt with benchmark awareness - const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements. + const systemPrompt = `You are a cloud infrastructure expert who recommends the RIGHT server by understanding user needs - whether they prioritize cost savings or performance. CRITICAL RULES: -1. NEVER over-provision. Recommend the minimum specs needed. -2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements. -3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads. -4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec. -5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready). -6. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id. -7. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate. +1. UNDERSTAND THE CONTEXT: Budget-conscious users and minimal workloads need different recommendations than high-performance scenarios. +2. BUDGET SCENARIOS (<50 concurrent users, personal blogs, portfolios): Basic 1GB/2GB are valid top recommendations. Cost is the PRIMARY factor. +3. HIGH-DEMAND SCENARIOS (500+ concurrent users, gaming, databases): Capacity is PRIMARY. Never undersized servers. +4. Provide 3 options with meaningful spec progression: Budget (cheapest adequate) → Best Fit (balanced) → Premium (growth ready). +5. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id. +6. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate. BANDWIDTH CONSIDERATIONS: - Estimated monthly bandwidth is provided based on concurrent users and use case. @@ -204,16 +203,34 @@ Return ONLY a valid JSON object (no markdown, no code blocks) with this exact st } Provide exactly 3 recommendations: -1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load -2. BALANCED option: Some headroom for traffic spikes -3. PREMIUM option: Ready for 2-3x growth +1. BEST FIT option: Best match for requirements (balance capacity and cost) +2. BUDGET option: Cheapest adequate option (MUST meet minimum requirements) +3. PREMIUM option: Higher-spec with room for 2-3x growth -SCORING (100 points total): -- Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score. -- Capacity Fit (30%): Can it handle the concurrent users and bandwidth? -- Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance. +SCORING RULES (100 points total): +Base scoring: +- Capacity Fit (50%): Can it handle concurrent users and bandwidth? +- Total Cost Efficiency (30%): Lower cost = higher score among adequate servers +- Scalability (20%): Room for growth -The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`; +BUDGET-CONSCIOUS SCENARIOS: +When user explicitly requests "cheapest", "저렴한", "최소", "가장 싼" OR has minimal requirements (<50 concurrent users, personal blog, portfolio): +- Basic 1GB or Basic 2GB servers MUST be considered as top recommendations +- The cheapest server that meets minimum requirements should score 85-95 points +- Cost efficiency becomes PRIMARY factor (50%) while capacity becomes SECONDARY (30%) +- For personal blogs (<100 daily visitors): Basic 1GB is often sufficient and should be #1 recommendation +- For small sites (100-500 daily visitors): Basic 2GB should be considered for #1 or #2 + +HIGH-DEMAND SCENARIOS: +For high-concurrency workloads (500+ concurrent users, high-traffic apps, gaming servers, databases): +- Capacity MUST be sufficient first - undersized servers should never score above 60 +- A 1GB server CANNOT handle 1000+ concurrent users - score it accordingly +- Standard 4GB or higher should be prioritized for demanding workloads + +CRITICAL VALIDATION: +- Never recommend the same server twice (different server_id required) +- Budget option should be 40-60% cheaper than Premium option when possible +- Ensure meaningful spec progression: Budget < Best Fit < Premium`; // Use AI Gateway if configured (bypasses regional restrictions like HKG) // AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai @@ -227,9 +244,9 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions'); } - // Create AbortController with 30 second timeout + // Create AbortController with configurable timeout const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.AI_REQUEST_MS); try { const openaiResponse = await fetch(apiEndpoint, { diff --git a/src/services/linode-provider.ts b/src/services/linode-provider.ts index 7fd1868..5d10641 100644 --- a/src/services/linode-provider.ts +++ b/src/services/linode-provider.ts @@ -5,6 +5,7 @@ import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types'; import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider'; +import { TIMEOUTS } from '../config'; interface LinodeInstance { id: number; @@ -28,7 +29,7 @@ interface LinodeError { export class LinodeProvider extends VPSProviderBase { static readonly DEFAULT_BASE_URL = 'https://api.linode.com/v4'; - constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) { + constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) { super({ apiKey, baseUrl: baseUrl || LinodeProvider.DEFAULT_BASE_URL, diff --git a/src/services/provisioning-service.ts b/src/services/provisioning-service.ts index 505992d..70e75e2 100644 --- a/src/services/provisioning-service.ts +++ b/src/services/provisioning-service.ts @@ -8,6 +8,9 @@ import { ProvisioningRepository } from '../repositories/ProvisioningRepository'; import { LinodeProvider } from './linode-provider'; import { VultrProvider } from './vultr-provider'; import { getExchangeRate } from '../utils/exchange-rate'; +import { TIMEOUTS } from '../config'; + +const TELEGRAM_TIMEOUT_MS = 10000; // 10 seconds for Telegram API calls export class ProvisioningService { private repo: ProvisioningRepository; @@ -40,7 +43,28 @@ export class ProvisioningService { * 6. Return immediately with order info */ async provisionServer(request: ProvisionRequest): Promise { - const { telegram_id, pricing_id, label, image, dry_run } = request; + const { telegram_id, pricing_id, label, image, dry_run, idempotency_key } = request; + + // Step 0: Check idempotency - if key exists, handle based on order status + let existingPendingOrder: ServerOrder | null = null; + + if (idempotency_key) { + const existingOrder = await this.repo.findOrderByIdempotencyKey(idempotency_key); + if (existingOrder) { + // If order is already active, return existing result (true idempotency) + if (existingOrder.status === 'active') { + console.log(`[ProvisioningService] Idempotent request: order ${existingOrder.id} already active, returning existing`); + return { + success: true, + order: existingOrder, + }; + } + // If order is pending/provisioning, store it to use instead of creating new + // This handles retry scenarios where previous attempt failed + existingPendingOrder = existingOrder; + console.log(`[ProvisioningService] Found pending order ${existingOrder.id} with idempotency_key, will use existing instead of creating new`); + } + } // Step 1: Validate OS image from DB (or get default) let osImage = image ? await this.repo.getOsImageByKey(image) : await this.repo.getDefaultOsImage(); @@ -102,6 +126,7 @@ export class ProvisioningService { order: { id: 0, user_id: user.id, + telegram_user_id: telegram_id, spec_id: pricing_id, status: 'pending', region: pricing.region_code, @@ -117,6 +142,7 @@ export class ProvisioningService { label: label || null, image: osImageKey, billing_type: 'monthly', + idempotency_key: idempotency_key || null, }, dry_run_info: { message: 'Dry run successful. No server created, no balance deducted.', @@ -132,36 +158,54 @@ export class ProvisioningService { } as ProvisionResponse; } - // Step 5: Generate root password before creating order - const rootPassword = this.generateSecurePassword(); + // Step 5 & 6: Use existing pending order OR create new order with payment + let orderId: number; - // Step 6: Create order with payment (atomic balance deduction + order creation) - const orderResult = await this.repo.createOrderWithPayment( - user.id, - pricing_id, - pricing.region_code, - priceKrw, - label || null, - osImageKey - ); + if (existingPendingOrder) { + // Use existing pending order - no need to create new or deduct balance again + orderId = existingPendingOrder.id; + console.log(`[ProvisioningService] Using existing order ${orderId} (idempotency)`); - if (!orderResult.orderId) { - return { - success: false, - error: { - code: orderResult.error || 'ORDER_CREATION_FAILED', - message: orderResult.error === 'INSUFFICIENT_BALANCE' - ? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}` - : 'Failed to create order', - }, - }; + // Ensure root_password is set (telegram-bot-workers doesn't set it) + if (!existingPendingOrder.root_password) { + const rootPassword = this.generateSecurePassword(); + await this.repo.updateOrderRootPassword(orderId, rootPassword); + console.log(`[ProvisioningService] Generated root_password for existing order ${orderId}`); + } + } else { + // Step 5: Generate root password before creating order + const rootPassword = this.generateSecurePassword(); + + // Step 6: Create order with payment (atomic balance deduction + order creation) + const orderResult = await this.repo.createOrderWithPayment( + user.id, + telegram_id, + pricing_id, + pricing.region_code, + priceKrw, + label || null, + osImageKey, + idempotency_key || null + ); + + if (!orderResult.orderId) { + return { + success: false, + error: { + code: orderResult.error || 'ORDER_CREATION_FAILED', + message: orderResult.error === 'INSUFFICIENT_BALANCE' + ? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}` + : 'Failed to create order', + }, + }; + } + + orderId = orderResult.orderId; + + // Step 7: Store root password in order (encrypted in production) + await this.repo.updateOrderRootPassword(orderId, rootPassword); } - const orderId = orderResult.orderId; - - // Step 7: Store root password in order (encrypted in production) - await this.repo.updateOrderRootPassword(orderId, rootPassword); - // Step 8: Update order status to 'queued' and send to Queue await this.repo.updateOrderStatus(orderId, 'provisioning'); @@ -211,7 +255,9 @@ export class ProvisioningService { const pricing = await this.repo.getPricingWithProvider(pricing_id); if (!pricing) { console.error(`[ProvisioningService] Pricing not found for order ${order_id}`); - await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found'); + const errorMsg = 'Pricing not found'; + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); + await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); return; } @@ -219,7 +265,9 @@ export class ProvisioningService { const provider = this.getProvider(pricing.source_provider); if (!provider) { console.error(`[ProvisioningService] Provider ${pricing.source_provider} not configured for order ${order_id}`); - await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `Provider ${pricing.source_provider} not configured`); + const errorMsg = `Provider ${pricing.source_provider} not configured`; + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); + await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); return; } @@ -227,7 +275,9 @@ export class ProvisioningService { const osImage = await this.repo.getOsImageByKey(image); if (!osImage) { console.error(`[ProvisioningService] OS image '${image}' not found for order ${order_id}`); - await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image '${image}' not found`); + const errorMsg = `OS image '${image}' not found`; + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); + await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); return; } @@ -238,10 +288,24 @@ export class ProvisioningService { if (!osImageId) { console.error(`[ProvisioningService] OS image '${image}' not available for ${pricing.source_provider}`); - await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image not available for ${pricing.source_provider}`); + const errorMsg = `OS image not available for ${pricing.source_provider}`; + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); + await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); return; } + // Prepare SSH keys (admin key for recovery access) + // Linode: uses public key string directly + // Vultr: uses pre-registered SSH key ID + let sshKeys: string[] | undefined; + if (pricing.provider_name.toLowerCase() === 'linode' && this.env.ADMIN_SSH_PUBLIC_KEY) { + sshKeys = [this.env.ADMIN_SSH_PUBLIC_KEY]; + console.log(`[ProvisioningService] Admin SSH public key will be added to order ${order_id} (Linode)`); + } else if (pricing.provider_name.toLowerCase() === 'vultr' && this.env.ADMIN_SSH_KEY_ID_VULTR) { + sshKeys = [this.env.ADMIN_SSH_KEY_ID_VULTR]; + console.log(`[ProvisioningService] Admin SSH key ID will be added to order ${order_id} (Vultr)`); + } + // Call provider API (use source_region_code for actual provider region) const createResult = await provider.createServer({ plan: pricing.instance_id, @@ -249,19 +313,17 @@ export class ProvisioningService { osImage: osImageId, label: label || `order-${order_id}`, rootPassword: root_password, + sshKeys, tags: [`user:${user_id}`, `order:${order_id}`], }); if (!createResult.success) { console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error); - await this.repo.rollbackOrder( - order_id, - user_id, - order.price_paid, - createResult.error?.message || 'Provider API error' - ); + const errorMsg = createResult.error?.message || 'Provider API error'; + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg); + await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid); // Throw error to trigger retry - throw new Error(createResult.error?.message || 'Provider API error'); + throw new Error(errorMsg); } // Wait for IP assignment if not immediately available @@ -284,6 +346,164 @@ export class ProvisioningService { ); console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`); + + // Send Telegram notification to user (now that we have real IP and password) + await this.sendProvisioningSuccessNotification( + order_id, + order.telegram_user_id, + ipv4 || null, + order.label, + pricing.region_name, + root_password + ); + } + + /** + * Send Telegram notification for provisioning failure + */ + private async sendProvisioningFailureNotification( + orderId: number, + telegramUserId: string, + errorMessage: string, + refundAmount: number + ): Promise { + // Check if BOT_TOKEN is available + if (!this.env.BOT_TOKEN) { + console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping failure notification for order ${orderId}`); + return; + } + + try { + const botToken = this.env.BOT_TOKEN; + const chatId = parseInt(telegramUserId, 10); + + // Validate chatId + if (isNaN(chatId) || chatId <= 0) { + console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`); + return; + } + + const failureMessage = `❌ 서버 프로비저닝 실패 + +주문 #${orderId} +사유: ${errorMessage} + +잔액 환불: ${refundAmount.toLocaleString()}원 + +다시 시도하시거나 관리자에게 문의해주세요.`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TELEGRAM_TIMEOUT_MS); + + try { + await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: failureMessage, + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + console.log(`[ProvisioningService] Sent failure notification for order ${orderId}`); + } catch (error) { + console.error(`[ProvisioningService] Failed to send failure notification for order ${orderId}:`, error); + // Don't throw - notification failure shouldn't affect the rollback + } + } + + /** + * Send Telegram notification after successful provisioning + */ + private async sendProvisioningSuccessNotification( + orderId: number, + telegramUserId: string, + ipAddress: string | null, + label: string | null, + region: string, + rootPassword: string + ): Promise { + // Check if BOT_TOKEN is available + if (!this.env.BOT_TOKEN) { + console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping Telegram notification for order ${orderId}`); + return; + } + + try { + const botToken = this.env.BOT_TOKEN; + const chatId = parseInt(telegramUserId, 10); + + // Validate chatId + if (isNaN(chatId) || chatId <= 0) { + console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`); + return; + } + + // Send main success message + const successMessage = `✅ 서버 프로비저닝 완료! + +주문 #${orderId} +IP 주소: ${ipAddress || 'IP 할당 대기 중'} +라벨: ${label || `order-${orderId}`} +리전: ${region} + +루트 비밀번호는 보안상 별도로 전송됩니다.`; + + // Send success message with timeout + const controller1 = new AbortController(); + const timeoutId1 = setTimeout(() => controller1.abort(), TELEGRAM_TIMEOUT_MS); + + try { + await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: successMessage, + }), + signal: controller1.signal, + }); + } finally { + clearTimeout(timeoutId1); + } + + console.log(`[ProvisioningService] Sent success notification for order ${orderId}`); + + // Send password in separate message (security best practice) + const passwordMessage = `🔐 서버 #${orderId} 루트 비밀번호: + +${rootPassword} + +⚠️ 이 메시지는 즉시 삭제하고 비밀번호를 안전한 곳에 보관하세요.`; + + // Send password message with timeout + const controller2 = new AbortController(); + const timeoutId2 = setTimeout(() => controller2.abort(), TELEGRAM_TIMEOUT_MS); + + try { + await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: passwordMessage, + parse_mode: 'HTML', + }), + signal: controller2.signal, + }); + } finally { + clearTimeout(timeoutId2); + } + + console.log(`[ProvisioningService] Sent password notification for order ${orderId}`); + } catch (error) { + console.error(`[ProvisioningService] Failed to send Telegram notification for order ${orderId}:`, error); + // Don't throw - notification failure shouldn't fail the provisioning + } } /** diff --git a/src/services/vps-provider.ts b/src/services/vps-provider.ts index 665ac1e..f35f3a2 100644 --- a/src/services/vps-provider.ts +++ b/src/services/vps-provider.ts @@ -84,16 +84,37 @@ export abstract class VPSProviderBase { } /** - * Generate cryptographically secure password + * Generate cryptographically secure password using rejection sampling + * to avoid modulo bias in character selection */ protected generateSecurePassword(length: number = 32): string { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; - const array = new Uint8Array(length); + // Calculate max valid value to avoid modulo bias (256 % 70 = 46, creates bias) + const maxValidValue = Math.floor(256 / chars.length) * chars.length; + // Request more bytes than needed for rejection sampling + const array = new Uint8Array(length * 2); crypto.getRandomValues(array); + let password = ''; - for (let i = 0; i < length; i++) { - password += chars[array[i] % chars.length]; + let i = 0; + while (password.length < length && i < array.length) { + if (array[i] < maxValidValue) { + password += chars[array[i] % chars.length]; + } + i++; } + + // Fallback: if we run out of valid bytes, get more + while (password.length < length) { + const extraBytes = new Uint8Array(length); + crypto.getRandomValues(extraBytes); + for (let j = 0; j < extraBytes.length && password.length < length; j++) { + if (extraBytes[j] < maxValidValue) { + password += chars[extraBytes[j] % chars.length]; + } + } + } + return password; } } diff --git a/src/services/vultr-provider.ts b/src/services/vultr-provider.ts index e31e64b..8bb02e2 100644 --- a/src/services/vultr-provider.ts +++ b/src/services/vultr-provider.ts @@ -5,6 +5,7 @@ import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types'; import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider'; +import { TIMEOUTS } from '../config'; interface VultrInstance { id: string; @@ -38,7 +39,7 @@ interface VultrError { export class VultrProvider extends VPSProviderBase { static readonly DEFAULT_BASE_URL = 'https://api.vultr.com/v2'; - constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) { + constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) { super({ apiKey, baseUrl: baseUrl || VultrProvider.DEFAULT_BASE_URL, diff --git a/src/types.ts b/src/types.ts index f5d3024..3119815 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,11 @@ export interface Env { PROVISION_API_KEY?: string; // Required for /api/provision/* endpoints // Queue for async provisioning PROVISION_QUEUE: Queue; + // Telegram Bot Token for user notifications + BOT_TOKEN?: string; // Optional: for sending Telegram notifications after provisioning + // Admin SSH keys for server recovery + ADMIN_SSH_PUBLIC_KEY?: string; // Linode: public key string (ssh-rsa AAAA...) + ADMIN_SSH_KEY_ID_VULTR?: string; // Vultr: pre-registered SSH key ID } // Queue message for async server provisioning @@ -253,6 +258,7 @@ export interface UserDeposit { export interface ServerOrder { id: number; // AUTO INCREMENT user_id: number; // References users.id + telegram_user_id: string; // Telegram user ID (for direct reference) spec_id: number; // Server spec ID status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated'; region: string; @@ -268,6 +274,7 @@ export interface ServerOrder { label: string | null; image: string | null; billing_type: string; // 'monthly' default + idempotency_key: string | null; // Idempotency key for duplicate prevention } export type VPSProvider = 'linode' | 'vultr'; @@ -278,6 +285,7 @@ export interface ProvisionRequest { label?: string; image?: string; // OS image (e.g., 'ubuntu_22_04') dry_run?: boolean; // Test mode: validate only, don't create server + idempotency_key?: string; // Idempotency key for duplicate prevention (1-128 chars) } export interface ProvisionResponse { diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 54e26db..bc00092 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -82,6 +82,18 @@ export function generateCacheKey(req: RecommendRequest): string { // In-memory fallback for rate limiting when CACHE KV is unavailable const inMemoryRateLimit = new Map(); +const MAX_IN_MEMORY_ENTRIES = 10000; + +/** + * Clean up expired entries from in-memory rate limit map + */ +function cleanupExpiredEntries(now: number): void { + for (const [key, record] of inMemoryRateLimit) { + if (record.resetTime < now) { + inMemoryRateLimit.delete(key); + } + } +} /** * Rate limiting check using KV storage with in-memory fallback @@ -94,6 +106,18 @@ export async function checkRateLimit(clientIP: string, env: Env): Promise<{ allo // Use in-memory fallback if CACHE unavailable if (!env.CACHE) { + // Cleanup expired entries if map is getting too large + if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) { + cleanupExpiredEntries(now); + // If still too large after cleanup, remove oldest 10% by resetTime + if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) { + const entries = Array.from(inMemoryRateLimit.entries()) + .sort((a, b) => a[1].resetTime - b[1].resetTime) + .slice(0, Math.floor(MAX_IN_MEMORY_ENTRIES * 0.1)); + entries.forEach(([key]) => inMemoryRateLimit.delete(key)); + } + } + const record = inMemoryRateLimit.get(clientIP); if (!record || record.resetTime < now) { diff --git a/src/utils/validation.ts b/src/utils/validation.ts index ff2d858..e726823 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -164,6 +164,20 @@ export function validateRecommendRequest(body: unknown, lang: string = 'en'): Va } } + // Validate cdn_enabled if provided + if (req.cdn_enabled !== undefined && typeof req.cdn_enabled !== 'boolean') { + invalidFields.push({ field: 'cdn_enabled', reason: 'must be a boolean' }); + } + + // Validate cdn_cache_hit_rate if provided + if (req.cdn_cache_hit_rate !== undefined) { + if (typeof req.cdn_cache_hit_rate !== 'number') { + invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be a number' }); + } else if (req.cdn_cache_hit_rate < 0 || req.cdn_cache_hit_rate > 1) { + invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be between 0.0 and 1.0' }); + } + } + // Return error if any issues found if (missingFields.length > 0 || invalidFields.length > 0) { return {