Files
cloud-server/src/routes/recommend.ts
kappa 5a9362bf43 feat: P1 보안/성능 개선 및 마이그레이션 자동화
Security fixes:
- migrate.ts: SQL/Command Injection 방지 (spawnSync 사용)
- migrate.ts: Path Traversal 검증 추가
- api-tester.ts: API 키 마스킹 (4자만 노출)
- api-tester.ts: 최소 16자 키 길이 검증
- cache.ts: ReDoS 방지 (패턴 길이/와일드카드 제한)

Performance improvements:
- cache.ts: 순차 삭제 → 병렬 배치 처리 (50개씩)
- cache.ts: KV 등록 fire-and-forget (non-blocking)
- cache.ts: 메모리 제한 (5000키)
- cache.ts: 25초 실행 시간 가드
- cache.ts: 패턴 매칭 prefix 최적화

New features:
- 마이그레이션 자동화 시스템 (scripts/migrate.ts)
- KV 기반 캐시 인덱스 (invalidatePattern, clearAll)
- 글로벌 CacheService 싱글톤

Other:
- .env.example 추가, API 키 환경변수 처리
- CACHE_TTL.RECOMMENDATIONS (10분) 분리
- e2e-tester.ts JSON 파싱 에러 핸들링 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:23:13 +09:00

284 lines
8.1 KiB
TypeScript

/**
* Recommendation Route Handler
*
* Endpoint for getting cloud instance recommendations based on tech stack.
* Validates request parameters and returns ranked instance recommendations.
*/
import type { Env, ScaleType } from '../types';
import { RecommendationService } from '../services/recommendation';
import { validateStack, STACK_REQUIREMENTS } from '../services/stackConfig';
import { getGlobalCacheService } from '../services/cache';
import { logger } from '../utils/logger';
import { HTTP_STATUS, CACHE_TTL, REQUEST_LIMITS } from '../constants';
import {
parseJsonBody,
validateStringArray,
validateEnum,
validatePositiveNumber,
createErrorResponse,
} from '../utils/validation';
/**
* Request body interface for recommendation endpoint
*/
interface RecommendRequestBody {
stack?: unknown;
scale?: unknown;
budget_max?: unknown;
}
/**
* Supported scale types
*/
const SUPPORTED_SCALES: readonly ScaleType[] = ['small', 'medium', 'large'] as const;
/**
* Handle POST /recommend endpoint
*
* @param request - HTTP request object
* @param env - Cloudflare Worker environment bindings
* @returns JSON response with recommendations
*
* @example
* POST /recommend
* {
* "stack": ["nginx", "mysql", "redis"],
* "scale": "medium",
* "budget_max": 100
* }
*/
export async function handleRecommend(request: Request, env: Env): Promise<Response> {
const startTime = Date.now();
logger.info('[Recommend] Request received');
try {
// 1. Validate request size to prevent memory exhaustion attacks
const contentLength = request.headers.get('content-length');
if (contentLength) {
const bodySize = parseInt(contentLength, 10);
if (isNaN(bodySize) || bodySize > REQUEST_LIMITS.MAX_BODY_SIZE) {
logger.error('[Recommend] Request body too large', {
contentLength: bodySize,
maxAllowed: REQUEST_LIMITS.MAX_BODY_SIZE,
});
return Response.json(
{
success: false,
error: {
code: 'PAYLOAD_TOO_LARGE',
message: `Request body exceeds maximum size of ${REQUEST_LIMITS.MAX_BODY_SIZE} bytes`,
details: {
max_size_bytes: REQUEST_LIMITS.MAX_BODY_SIZE,
received_bytes: bodySize,
},
},
},
{ status: HTTP_STATUS.PAYLOAD_TOO_LARGE }
);
}
}
// 2. Parse request body
const parseResult = await parseJsonBody<RecommendRequestBody>(request);
if (!parseResult.success) {
logger.error('[Recommend] JSON parsing failed', {
code: parseResult.error.code,
message: parseResult.error.message,
});
return createErrorResponse(parseResult.error);
}
const body = parseResult.data;
// 3. Validate stack parameter
const stackResult = validateStringArray(body.stack, 'stack');
if (!stackResult.success) {
logger.error('[Recommend] Stack validation failed', {
code: stackResult.error.code,
message: stackResult.error.message,
});
// Add supported stacks to error details
const enrichedError = {
...stackResult.error,
details: {
...((stackResult.error.details as object) || {}),
supported: Object.keys(STACK_REQUIREMENTS),
},
};
return createErrorResponse(enrichedError);
}
const stack = stackResult.data;
// 4. Validate scale parameter
const scaleResult = validateEnum(body.scale, 'scale', SUPPORTED_SCALES);
if (!scaleResult.success) {
logger.error('[Recommend] Scale validation failed', {
code: scaleResult.error.code,
message: scaleResult.error.message,
});
return createErrorResponse(scaleResult.error);
}
const scale = scaleResult.data;
// 5. Validate budget_max parameter (optional)
let budgetMax: number | undefined;
if (body.budget_max !== undefined) {
const budgetResult = validatePositiveNumber(body.budget_max, 'budget_max');
if (!budgetResult.success) {
logger.error('[Recommend] Budget validation failed', {
code: budgetResult.error.code,
message: budgetResult.error.message,
});
return createErrorResponse(budgetResult.error);
}
budgetMax = budgetResult.data;
}
// 6. Validate stack components against supported technologies
const validation = validateStack(stack);
if (!validation.valid) {
logger.error('[Recommend] Unsupported stack components', {
invalidStacks: validation.invalidStacks,
});
return createErrorResponse({
code: 'INVALID_STACK',
message: `Unsupported stacks: ${validation.invalidStacks.join(', ')}`,
details: {
invalid: validation.invalidStacks,
supported: Object.keys(STACK_REQUIREMENTS),
},
});
}
// 7. Initialize cache service and generate cache key
logger.info('[Recommend] Validation passed', { stack, scale, budgetMax });
const cacheService = getGlobalCacheService(CACHE_TTL.RECOMMENDATIONS, env.RATE_LIMIT_KV);
// Generate cache key from sorted stack, scale, and budget
// Sort stack to ensure consistent cache keys regardless of order
const sortedStack = [...stack].sort();
const cacheKey = cacheService.generateKey({
endpoint: 'recommend',
stack: sortedStack.join(','),
scale,
budget_max: budgetMax ?? 'none',
});
logger.info('[Recommend] Cache key generated', { cacheKey });
// 8. Check cache first
interface CachedRecommendation {
recommendations: unknown[];
stack_analysis: unknown;
metadata: {
cached?: boolean;
cache_age_seconds?: number;
cached_at?: string;
query_time_ms: number;
};
}
const cached = await cacheService.get<CachedRecommendation>(cacheKey);
if (cached) {
logger.info('[Recommend] Cache hit', {
cacheKey,
age: cached.cache_age_seconds,
});
return Response.json(
{
success: true,
data: {
...cached.data,
metadata: {
...cached.data.metadata,
cached: true,
cache_age_seconds: cached.cache_age_seconds,
cached_at: cached.cached_at,
},
},
},
{
status: HTTP_STATUS.OK,
headers: {
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
},
}
);
}
logger.info('[Recommend] Cache miss');
// 9. Call recommendation service
const service = new RecommendationService(env.DB);
const result = await service.recommend({
stack,
scale,
budget_max: budgetMax,
});
const duration = Date.now() - startTime;
logger.info('[Recommend] Recommendation completed', {
duration_ms: duration,
recommendations_count: result.recommendations.length,
});
// Prepare response data with metadata
const responseData = {
...result,
metadata: {
cached: false,
query_time_ms: duration,
},
};
// 10. Store result in cache
try {
await cacheService.set(cacheKey, responseData, CACHE_TTL.RECOMMENDATIONS);
} catch (error) {
// Graceful degradation: log error but don't fail the request
logger.error('[Recommend] Cache write failed',
error instanceof Error ? { message: error.message } : { error: String(error) });
}
return Response.json(
{
success: true,
data: responseData,
},
{
status: HTTP_STATUS.OK,
headers: {
'Cache-Control': `public, max-age=${CACHE_TTL.RECOMMENDATIONS}`,
},
}
);
} catch (error) {
logger.error('[Recommend] Unexpected error', { error });
const duration = Date.now() - startTime;
return Response.json(
{
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Recommendation request failed. Please try again later.',
request_id: crypto.randomUUID(),
details: {
duration_ms: duration,
},
},
},
{ status: HTTP_STATUS.INTERNAL_ERROR }
);
}
}