refactor: major architecture improvements and security hardening
## Security Fixes
- Fix XSS vulnerability in report.ts with escapeHtml()
- Add cache data integrity validation
- Add region_preference input validation (max 10 items, 50 chars each)
- Replace `any` types with `unknown` + type guards
## Architecture Refactoring
- Split utils.ts (801 lines) into 6 modules: http, validation, bandwidth, cache, ai, exchange-rate
- Extract AI logic to src/services/ai-service.ts (recommend.ts 49% reduction)
- Add Repository pattern: src/repositories/AnvilServerRepository.ts
- Reduce code duplication in DB queries
## New Features
- AI fallback: rule-based recommendations when OpenAI unavailable
- Vitest testing: 55 tests (utils.test.ts, bandwidth.test.ts)
- Duplicate server prevention in AI recommendations
## Files Added
- src/utils/{index,http,validation,bandwidth,cache,ai,exchange-rate}.ts
- src/services/ai-service.ts
- src/repositories/AnvilServerRepository.ts
- src/__tests__/{utils,bandwidth}.test.ts
- vitest.config.ts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
39
src/utils/ai.ts
Normal file
39
src/utils/ai.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* AI utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitize user input for AI prompts to prevent prompt injection
|
||||
*/
|
||||
export function sanitizeForAIPrompt(input: string, maxLength: number = 200): string {
|
||||
// 1. Normalize Unicode (NFKC form collapses homoglyphs)
|
||||
let sanitized = input.normalize('NFKC');
|
||||
|
||||
// 2. Remove zero-width characters
|
||||
sanitized = sanitized.replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '');
|
||||
|
||||
// 3. Expanded blocklist patterns
|
||||
const dangerousPatterns = [
|
||||
/ignore\s*(all|previous|above)?\s*instruction/gi,
|
||||
/system\s*prompt/gi,
|
||||
/you\s*are\s*(now|a)/gi,
|
||||
/pretend\s*(to\s*be|you)/gi,
|
||||
/act\s*as/gi,
|
||||
/disregard/gi,
|
||||
/forget\s*(everything|all|previous)/gi,
|
||||
/new\s*instruction/gi,
|
||||
/override/gi,
|
||||
/\[system\]/gi,
|
||||
/<\|im_start\|>/gi,
|
||||
/<\|im_end\|>/gi,
|
||||
/```[\s\S]*?```/g, // Code blocks that might contain injection
|
||||
/"""/g, // Triple quotes
|
||||
/---+/g, // Horizontal rules/delimiters
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
sanitized = sanitized.replace(pattern, '[filtered]');
|
||||
}
|
||||
|
||||
return sanitized.slice(0, maxLength);
|
||||
}
|
||||
329
src/utils/bandwidth.ts
Normal file
329
src/utils/bandwidth.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Bandwidth estimation utility functions
|
||||
*/
|
||||
|
||||
import type { BandwidthEstimate, BandwidthInfo, UseCaseConfig } from '../types';
|
||||
import { USE_CASE_CONFIGS } from '../config';
|
||||
|
||||
/**
|
||||
* Find use case configuration by matching patterns
|
||||
*/
|
||||
export function findUseCaseConfig(useCase: string): UseCaseConfig {
|
||||
const useCaseLower = useCase.toLowerCase();
|
||||
|
||||
for (const config of USE_CASE_CONFIGS) {
|
||||
if (config.patterns.test(useCaseLower)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
return {
|
||||
category: 'default',
|
||||
patterns: /.*/,
|
||||
dauMultiplier: { min: 10, max: 14 },
|
||||
activeRatio: 0.5
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DAU multiplier based on use case (how many daily active users per concurrent user)
|
||||
*/
|
||||
export function getDauMultiplier(useCase: string): { min: number; max: number } {
|
||||
return findUseCaseConfig(useCase).dauMultiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active user ratio (what percentage of DAU actually performs the bandwidth-heavy action)
|
||||
*/
|
||||
export function getActiveUserRatio(useCase: string): number {
|
||||
return findUseCaseConfig(useCase).activeRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate monthly bandwidth based on concurrent users and use case
|
||||
*/
|
||||
export function estimateBandwidth(concurrentUsers: number, useCase: string, trafficPattern?: string): BandwidthEstimate {
|
||||
const useCaseLower = useCase.toLowerCase();
|
||||
|
||||
// Get use case configuration
|
||||
const config = findUseCaseConfig(useCase);
|
||||
const useCaseCategory = config.category;
|
||||
|
||||
// Calculate DAU estimate from concurrent users with use-case-specific multipliers
|
||||
const dauMultiplier = config.dauMultiplier;
|
||||
const estimatedDauMin = Math.round(concurrentUsers * dauMultiplier.min);
|
||||
const estimatedDauMax = Math.round(concurrentUsers * dauMultiplier.max);
|
||||
const dailyUniqueVisitors = Math.round((estimatedDauMin + estimatedDauMax) / 2);
|
||||
const activeUserRatio = config.activeRatio;
|
||||
const activeDau = Math.round(dailyUniqueVisitors * activeUserRatio);
|
||||
|
||||
// Traffic pattern adjustment
|
||||
let patternMultiplier = 1.0;
|
||||
if (trafficPattern === 'spiky') {
|
||||
patternMultiplier = 1.5; // Account for peak loads
|
||||
} else if (trafficPattern === 'growing') {
|
||||
patternMultiplier = 1.3; // Headroom for growth
|
||||
}
|
||||
|
||||
let dailyBandwidthGB: number;
|
||||
let bandwidthModel: string;
|
||||
|
||||
// ========== IMPROVED BANDWIDTH MODELS ==========
|
||||
// Each use case uses the most appropriate calculation method
|
||||
|
||||
switch (useCaseCategory) {
|
||||
case 'video': {
|
||||
// VIDEO/STREAMING: Bitrate-based model
|
||||
const is4K = /4k|uhd|ultra/i.test(useCaseLower);
|
||||
const bitrateGBperHour = is4K ? 11.25 : 2.25; // 4K vs HD
|
||||
const avgWatchTimeHours = is4K ? 1.0 : 1.5;
|
||||
const gbPerActiveUser = bitrateGBperHour * avgWatchTimeHours;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `bitrate-based: ${activeDau} active × ${bitrateGBperHour} GB/hr × ${avgWatchTimeHours}hr`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'file': {
|
||||
// FILE DOWNLOAD: File-size based model
|
||||
const isLargeFiles = /iso|video|backup|대용량/.test(useCaseLower);
|
||||
const avgFileSizeGB = isLargeFiles ? 2.0 : 0.2;
|
||||
const downloadsPerUser = isLargeFiles ? 1 : 3;
|
||||
const gbPerActiveUser = avgFileSizeGB * downloadsPerUser;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `file-based: ${activeDau} active × ${avgFileSizeGB} GB × ${downloadsPerUser} downloads`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gaming': {
|
||||
// GAMING: Session-duration based model
|
||||
const isMinecraft = /minecraft|마인크래프트/.test(useCaseLower);
|
||||
const mbPerHour = isMinecraft ? 150 : 80;
|
||||
const avgSessionHours = isMinecraft ? 3 : 2.5;
|
||||
const gbPerActiveUser = (mbPerHour * avgSessionHours) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `session-based: ${activeDau} active × ${mbPerHour} MB/hr × ${avgSessionHours}hr`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'api': {
|
||||
// API/SAAS: Request-based model
|
||||
const avgRequestKB = 20;
|
||||
const requestsPerUserPerDay = 1000;
|
||||
const gbPerActiveUser = (avgRequestKB * requestsPerUserPerDay) / (1024 * 1024);
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `request-based: ${activeDau} active × ${avgRequestKB}KB × ${requestsPerUserPerDay} req`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ecommerce': {
|
||||
// E-COMMERCE: Page-based model (images heavy)
|
||||
const avgPageSizeMB = 2.5;
|
||||
const pagesPerSession = 20;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'forum': {
|
||||
// FORUM/COMMUNITY: Page-based model (text + some images)
|
||||
const avgPageSizeMB = 0.7;
|
||||
const pagesPerSession = 30;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blog': {
|
||||
// STATIC/BLOG: Lightweight page-based model
|
||||
const avgPageSizeMB = 1.5;
|
||||
const pagesPerSession = 4;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based: ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
// CHAT/MESSAGING: Message-based model
|
||||
const textBandwidthMB = (3 * 200) / 1024; // 3KB × 200 messages
|
||||
const attachmentBandwidthMB = 20; // occasional images/files
|
||||
const gbPerActiveUser = (textBandwidthMB + attachmentBandwidthMB) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `message-based: ${activeDau} active × ~20MB/user (text+attachments)`;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// DEFAULT: General web app (page-based)
|
||||
const avgPageSizeMB = 1.0;
|
||||
const pagesPerSession = 10;
|
||||
const gbPerActiveUser = (avgPageSizeMB * pagesPerSession) / 1024;
|
||||
dailyBandwidthGB = activeDau * gbPerActiveUser * patternMultiplier;
|
||||
bandwidthModel = `page-based (default): ${activeDau} active × ${avgPageSizeMB}MB × ${pagesPerSession} pages`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Bandwidth] Model: ${bandwidthModel}`);
|
||||
console.log(`[Bandwidth] DAU: ${dailyUniqueVisitors} (${dauMultiplier.min}-${dauMultiplier.max}x), Active: ${activeDau} (${(activeUserRatio * 100).toFixed(0)}%), Daily: ${dailyBandwidthGB.toFixed(1)} GB`);
|
||||
|
||||
// Monthly bandwidth
|
||||
const monthlyGB = dailyBandwidthGB * 30;
|
||||
const monthlyTB = monthlyGB / 1024;
|
||||
|
||||
// Categorize
|
||||
let category: 'light' | 'moderate' | 'heavy' | 'very_heavy';
|
||||
let description: string;
|
||||
|
||||
if (monthlyTB < 0.5) {
|
||||
category = 'light';
|
||||
description = `~${Math.round(monthlyGB)} GB/month - Most VPS plans include sufficient bandwidth`;
|
||||
} else if (monthlyTB < 2) {
|
||||
category = 'moderate';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - Check provider bandwidth limits`;
|
||||
} else if (monthlyTB < 6) {
|
||||
category = 'heavy';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - Prefer providers with generous bandwidth (Linode: 1-6TB included)`;
|
||||
} else {
|
||||
category = 'very_heavy';
|
||||
description = `~${monthlyTB.toFixed(1)} TB/month - HIGH BANDWIDTH: Linode strongly recommended for cost savings`;
|
||||
}
|
||||
|
||||
return {
|
||||
monthly_gb: Math.round(monthlyGB),
|
||||
monthly_tb: Math.round(monthlyTB * 10) / 10,
|
||||
daily_gb: Math.round(dailyBandwidthGB * 10) / 10,
|
||||
category,
|
||||
description,
|
||||
estimated_dau_min: estimatedDauMin,
|
||||
estimated_dau_max: estimatedDauMax,
|
||||
active_ratio: activeUserRatio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider bandwidth allocation based on memory size
|
||||
* Returns included transfer in TB/month
|
||||
*/
|
||||
export function getProviderBandwidthAllocation(providerName: string, memoryGb: number): {
|
||||
included_tb: number;
|
||||
overage_per_gb: number;
|
||||
overage_per_tb: number;
|
||||
} {
|
||||
const provider = providerName.toLowerCase();
|
||||
|
||||
if (provider.includes('linode')) {
|
||||
// Linode: roughly 1TB per 1GB RAM (Nanode 1GB = 1TB, 2GB = 2TB, etc.)
|
||||
const includedTb = Math.min(Math.max(memoryGb, 1), 20);
|
||||
return {
|
||||
included_tb: includedTb,
|
||||
overage_per_gb: 0.005, // $0.005/GB = $5/TB
|
||||
overage_per_tb: 5
|
||||
};
|
||||
} else if (provider.includes('vultr')) {
|
||||
// Vultr: varies by plan, roughly 1-2TB for small, up to 10TB for large
|
||||
let includedTb: number;
|
||||
if (memoryGb <= 2) includedTb = 1;
|
||||
else if (memoryGb <= 4) includedTb = 2;
|
||||
else if (memoryGb <= 8) includedTb = 3;
|
||||
else if (memoryGb <= 16) includedTb = 4;
|
||||
else if (memoryGb <= 32) includedTb = 5;
|
||||
else includedTb = Math.min(memoryGb / 4, 10);
|
||||
|
||||
return {
|
||||
included_tb: includedTb,
|
||||
overage_per_gb: 0.01, // $0.01/GB = $10/TB
|
||||
overage_per_tb: 10
|
||||
};
|
||||
} else {
|
||||
// Default/Other providers: conservative estimate
|
||||
return {
|
||||
included_tb: Math.min(memoryGb, 5),
|
||||
overage_per_gb: 0.01,
|
||||
overage_per_tb: 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bandwidth cost info for a server
|
||||
* Uses actual DB values from anvil_transfer_pricing when available
|
||||
* @param server Server object
|
||||
* @param bandwidthEstimate Bandwidth estimate
|
||||
* @param lang Language code (ko = KRW, others = USD)
|
||||
* @param exchangeRate Exchange rate (USD to KRW)
|
||||
*/
|
||||
export function calculateBandwidthInfo(
|
||||
server: import('../types').Server,
|
||||
bandwidthEstimate: BandwidthEstimate,
|
||||
lang: string = 'en',
|
||||
exchangeRate: number = 1
|
||||
): BandwidthInfo {
|
||||
// Use actual DB values if available (Anvil servers), fallback to provider-based estimation
|
||||
let includedTb: number;
|
||||
let overagePerGbUsd: number;
|
||||
let overagePerTbUsd: number;
|
||||
|
||||
if (server.transfer_tb !== null && server.transfer_price_per_gb !== null) {
|
||||
// Use actual values from anvil_instances + anvil_transfer_pricing
|
||||
includedTb = server.transfer_tb;
|
||||
overagePerGbUsd = server.transfer_price_per_gb;
|
||||
overagePerTbUsd = server.transfer_price_per_gb * 1024;
|
||||
} else {
|
||||
// Fallback to provider-based estimation for non-Anvil servers
|
||||
const allocation = getProviderBandwidthAllocation(server.provider_name, server.memory_gb);
|
||||
includedTb = allocation.included_tb;
|
||||
overagePerGbUsd = allocation.overage_per_gb;
|
||||
overagePerTbUsd = allocation.overage_per_tb;
|
||||
}
|
||||
|
||||
const estimatedTb = bandwidthEstimate.monthly_tb;
|
||||
const overageTb = Math.max(0, estimatedTb - includedTb);
|
||||
const overageCostUsd = overageTb * overagePerTbUsd;
|
||||
|
||||
// Get server price in USD for total calculation
|
||||
const serverPriceUsd = server.currency === 'KRW'
|
||||
? server.monthly_price / exchangeRate
|
||||
: server.monthly_price;
|
||||
|
||||
const totalCostUsd = serverPriceUsd + overageCostUsd;
|
||||
|
||||
// Convert to KRW if Korean language, round to nearest 100
|
||||
const isKorean = lang === 'ko';
|
||||
const currency: 'USD' | 'KRW' = isKorean ? 'KRW' : 'USD';
|
||||
|
||||
// KRW: GB당은 1원 단위, TB당/총 비용은 100원 단위 반올림
|
||||
const roundKrw100 = (usd: number) => Math.round((usd * exchangeRate) / 100) * 100;
|
||||
const toKrw = (usd: number) => Math.round(usd * exchangeRate);
|
||||
|
||||
const overagePerGb = isKorean ? toKrw(overagePerGbUsd) : overagePerGbUsd;
|
||||
const overagePerTb = isKorean ? roundKrw100(overagePerTbUsd) : overagePerTbUsd;
|
||||
const overageCost = isKorean ? roundKrw100(overageCostUsd) : Math.round(overageCostUsd * 100) / 100;
|
||||
const totalCost = isKorean ? roundKrw100(totalCostUsd) : Math.round(totalCostUsd * 100) / 100;
|
||||
|
||||
let warning: string | undefined;
|
||||
if (overageTb > includedTb) {
|
||||
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||||
warning = `⚠️ 예상 트래픽(${estimatedTb.toFixed(1)}TB)이 기본 포함량(${includedTb}TB)의 2배 이상입니다. 상위 플랜을 고려하세요.`;
|
||||
} else if (overageTb > 0) {
|
||||
const costStr = isKorean ? `₩${overageCost.toLocaleString()}` : `$${overageCost.toFixed(0)}`;
|
||||
warning = isKorean
|
||||
? `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`
|
||||
: `예상 초과 트래픽: ${overageTb.toFixed(1)}TB (추가 비용 ~${costStr}/월)`;
|
||||
}
|
||||
|
||||
return {
|
||||
included_transfer_tb: includedTb,
|
||||
overage_cost_per_gb: isKorean ? Math.round(overagePerGb) : Math.round(overagePerGb * 10000) / 10000,
|
||||
overage_cost_per_tb: isKorean ? Math.round(overagePerTb) : Math.round(overagePerTb * 100) / 100,
|
||||
estimated_monthly_tb: Math.round(estimatedTb * 10) / 10,
|
||||
estimated_overage_tb: Math.round(overageTb * 10) / 10,
|
||||
estimated_overage_cost: overageCost,
|
||||
total_estimated_cost: totalCost,
|
||||
currency,
|
||||
warning
|
||||
};
|
||||
}
|
||||
140
src/utils/cache.ts
Normal file
140
src/utils/cache.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Cache and rate limiting utility functions
|
||||
*/
|
||||
|
||||
import type { RecommendRequest, Env } from '../types';
|
||||
import { LIMITS } from '../config';
|
||||
|
||||
/**
|
||||
* Simple hash function for strings
|
||||
*/
|
||||
export function hashString(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
// Use >>> 0 to convert to unsigned 32-bit integer
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize special characters for cache key
|
||||
*/
|
||||
export function sanitizeCacheValue(value: string): string {
|
||||
// Use URL-safe base64 encoding to avoid collisions
|
||||
try {
|
||||
return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
} catch {
|
||||
// Fallback for non-ASCII characters
|
||||
return encodeURIComponent(value).replace(/[%]/g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key from request parameters
|
||||
*/
|
||||
export function generateCacheKey(req: RecommendRequest): string {
|
||||
// Don't mutate original arrays - create sorted copies
|
||||
const sortedStack = [...req.tech_stack].sort();
|
||||
const sanitizedStack = sortedStack.map(sanitizeCacheValue).join(',');
|
||||
|
||||
// Hash use_case to avoid special characters and length issues
|
||||
const useCaseHash = hashString(req.use_case);
|
||||
|
||||
const parts = [
|
||||
`stack:${sanitizedStack}`,
|
||||
`users:${req.expected_users}`,
|
||||
`case:${useCaseHash}`,
|
||||
];
|
||||
|
||||
if (req.traffic_pattern) {
|
||||
parts.push(`traffic:${req.traffic_pattern}`);
|
||||
}
|
||||
|
||||
if (req.budget_limit) {
|
||||
parts.push(`budget:${req.budget_limit}`);
|
||||
}
|
||||
|
||||
// Include region preference in cache key
|
||||
if (req.region_preference && req.region_preference.length > 0) {
|
||||
const sortedRegions = [...req.region_preference].sort();
|
||||
const sanitizedRegions = sortedRegions.map(sanitizeCacheValue).join(',');
|
||||
parts.push(`region:${sanitizedRegions}`);
|
||||
}
|
||||
|
||||
// Include language in cache key
|
||||
if (req.lang) {
|
||||
parts.push(`lang:${req.lang}`);
|
||||
}
|
||||
|
||||
return `recommend:${parts.join('|')}`;
|
||||
}
|
||||
|
||||
// In-memory fallback for rate limiting when CACHE KV is unavailable
|
||||
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
/**
|
||||
* Rate limiting check using KV storage with in-memory fallback
|
||||
*/
|
||||
export async function checkRateLimit(clientIP: string, env: 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;
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// KV-based rate limiting
|
||||
const kvKey = `ratelimit:${clientIP}`;
|
||||
|
||||
try {
|
||||
const recordJson = await env.CACHE.get(kvKey);
|
||||
const record = recordJson ? JSON.parse(recordJson) as { count: number; resetTime: number } : null;
|
||||
|
||||
if (!record || record.resetTime < now) {
|
||||
// New window
|
||||
await env.CACHE.put(
|
||||
kvKey,
|
||||
JSON.stringify({ count: 1, resetTime: now + windowMs }),
|
||||
{ expirationTtl: 60 }
|
||||
);
|
||||
return { allowed: true, requestId };
|
||||
}
|
||||
|
||||
if (record.count >= maxRequests) {
|
||||
return { allowed: false, requestId };
|
||||
}
|
||||
|
||||
// Increment count
|
||||
record.count++;
|
||||
await env.CACHE.put(
|
||||
kvKey,
|
||||
JSON.stringify(record),
|
||||
{ expirationTtl: 60 }
|
||||
);
|
||||
return { allowed: true, requestId };
|
||||
} catch (error) {
|
||||
console.error('[RateLimit] KV error:', error);
|
||||
// On error, deny the request (fail closed) for security
|
||||
return { allowed: false, requestId };
|
||||
}
|
||||
}
|
||||
83
src/utils/exchange-rate.ts
Normal file
83
src/utils/exchange-rate.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Exchange rate utility functions
|
||||
*/
|
||||
|
||||
import type { Env, ExchangeRateCache } from '../types';
|
||||
|
||||
/**
|
||||
* Exchange rate constants
|
||||
*/
|
||||
const EXCHANGE_RATE_CACHE_KEY = 'exchange_rate:USD_KRW';
|
||||
const EXCHANGE_RATE_TTL_SECONDS = 3600; // 1 hour
|
||||
export const EXCHANGE_RATE_FALLBACK = 1450; // Fallback KRW rate if API fails
|
||||
|
||||
/**
|
||||
* Get USD to KRW exchange rate with KV caching
|
||||
* Uses open.er-api.com free API
|
||||
*/
|
||||
export async function getExchangeRate(env: Env): Promise<number> {
|
||||
// Try to get cached rate from KV
|
||||
if (env.CACHE) {
|
||||
try {
|
||||
const cached = await env.CACHE.get(EXCHANGE_RATE_CACHE_KEY);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached) as ExchangeRateCache;
|
||||
console.log(`[ExchangeRate] Using cached rate: ${data.rate}`);
|
||||
return data.rate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ExchangeRate] Cache read error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh rate from API
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { rates?: { KRW?: number } };
|
||||
const rate = data?.rates?.KRW;
|
||||
|
||||
if (!rate || typeof rate !== 'number' || rate < 1000 || rate > 2000) {
|
||||
console.warn('[ExchangeRate] Invalid rate from API:', rate);
|
||||
return EXCHANGE_RATE_FALLBACK;
|
||||
}
|
||||
|
||||
console.log(`[ExchangeRate] Fetched fresh rate: ${rate}`);
|
||||
|
||||
// Cache the rate
|
||||
if (env.CACHE) {
|
||||
try {
|
||||
const cacheData: ExchangeRateCache = {
|
||||
rate,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await env.CACHE.put(EXCHANGE_RATE_CACHE_KEY, JSON.stringify(cacheData), {
|
||||
expirationTtl: EXCHANGE_RATE_TTL_SECONDS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[ExchangeRate] Cache write error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return rate;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.warn('[ExchangeRate] Request timed out, using fallback');
|
||||
} else {
|
||||
console.error('[ExchangeRate] API error:', error);
|
||||
}
|
||||
return EXCHANGE_RATE_FALLBACK;
|
||||
}
|
||||
}
|
||||
63
src/utils/http.ts
Normal file
63
src/utils/http.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* HTTP utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS
|
||||
*/
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON response helper
|
||||
*/
|
||||
export function jsonResponse<T>(
|
||||
data: T,
|
||||
status: number,
|
||||
headers: Record<string, string> = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Security-Policy': "default-src 'none'",
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
'Cache-Control': 'no-store',
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get allowed CORS origin
|
||||
*/
|
||||
export function getAllowedOrigin(request: Request): string {
|
||||
const allowedOrigins = [
|
||||
'https://server-recommend.kappa-d8e.workers.dev',
|
||||
];
|
||||
const origin = request.headers.get('Origin');
|
||||
|
||||
// If Origin is provided and matches allowed list, return it
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
// For requests without Origin (non-browser: curl, API clients, server-to-server)
|
||||
// Return empty string - CORS headers won't be sent but request is still processed
|
||||
// This is safe because CORS only affects browser requests
|
||||
if (!origin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Origin provided but not in allowed list - return first allowed origin
|
||||
// Browser will block the response due to CORS mismatch
|
||||
return allowedOrigins[0];
|
||||
}
|
||||
59
src/utils/index.ts
Normal file
59
src/utils/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Central export point for all utility functions
|
||||
* Organized by domain responsibility
|
||||
*/
|
||||
|
||||
// HTTP utilities (responses, CORS, XSS protection)
|
||||
export {
|
||||
escapeHtml,
|
||||
jsonResponse,
|
||||
getAllowedOrigin
|
||||
} from './http';
|
||||
|
||||
// Validation utilities (type guards, request validation)
|
||||
export {
|
||||
isValidServer,
|
||||
isValidVPSBenchmark,
|
||||
isValidTechSpec,
|
||||
isValidBenchmarkData,
|
||||
isValidAIRecommendation,
|
||||
validateRecommendRequest
|
||||
} from './validation';
|
||||
|
||||
// Bandwidth estimation utilities
|
||||
export {
|
||||
findUseCaseConfig,
|
||||
getDauMultiplier,
|
||||
getActiveUserRatio,
|
||||
estimateBandwidth,
|
||||
getProviderBandwidthAllocation,
|
||||
calculateBandwidthInfo
|
||||
} from './bandwidth';
|
||||
|
||||
// Cache and rate limiting utilities
|
||||
export {
|
||||
hashString,
|
||||
sanitizeCacheValue,
|
||||
generateCacheKey,
|
||||
checkRateLimit
|
||||
} from './cache';
|
||||
|
||||
// AI utilities (prompt sanitization)
|
||||
export {
|
||||
sanitizeForAIPrompt
|
||||
} from './ai';
|
||||
|
||||
// Exchange rate utilities
|
||||
export {
|
||||
getExchangeRate,
|
||||
EXCHANGE_RATE_FALLBACK
|
||||
} from './exchange-rate';
|
||||
|
||||
// Re-export region utilities from region-utils.ts for backward compatibility
|
||||
export {
|
||||
DEFAULT_ANVIL_REGION_FILTER_SQL,
|
||||
COUNTRY_NAME_TO_REGIONS,
|
||||
escapeLikePattern,
|
||||
buildFlexibleRegionConditions,
|
||||
buildFlexibleRegionConditionsAnvil
|
||||
} from '../region-utils';
|
||||
179
src/utils/validation.ts
Normal file
179
src/utils/validation.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Validation utility functions
|
||||
*/
|
||||
|
||||
import type {
|
||||
RecommendRequest,
|
||||
ValidationError,
|
||||
Server,
|
||||
VPSBenchmark,
|
||||
TechSpec,
|
||||
BenchmarkData,
|
||||
AIRecommendationResponse
|
||||
} from '../types';
|
||||
import { i18n, LIMITS } from '../config';
|
||||
|
||||
/**
|
||||
* Type guard to validate Server object structure
|
||||
*/
|
||||
export function isValidServer(obj: unknown): obj is Server {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const s = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof s.id === 'number' &&
|
||||
typeof s.provider_name === 'string' &&
|
||||
typeof s.instance_id === 'string' &&
|
||||
typeof s.vcpu === 'number' &&
|
||||
typeof s.memory_mb === 'number' &&
|
||||
typeof s.monthly_price === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate VPSBenchmark object structure
|
||||
*/
|
||||
export function isValidVPSBenchmark(obj: unknown): obj is VPSBenchmark {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const v = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.id === 'number' &&
|
||||
typeof v.provider_name === 'string' &&
|
||||
typeof v.vcpu === 'number' &&
|
||||
typeof v.geekbench_single === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate TechSpec object structure
|
||||
*/
|
||||
export function isValidTechSpec(obj: unknown): obj is TechSpec {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const t = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof t.id === 'number' &&
|
||||
typeof t.name === 'string' &&
|
||||
typeof t.vcpu_per_users === 'number' &&
|
||||
typeof t.min_memory_mb === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate BenchmarkData object structure
|
||||
*/
|
||||
export function isValidBenchmarkData(obj: unknown): obj is BenchmarkData {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const b = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof b.id === 'number' &&
|
||||
typeof b.processor_name === 'string' &&
|
||||
typeof b.benchmark_name === 'string' &&
|
||||
typeof b.score === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate AI recommendation structure
|
||||
*/
|
||||
export function isValidAIRecommendation(obj: unknown): obj is AIRecommendationResponse['recommendations'][0] {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const r = obj as Record<string, unknown>;
|
||||
return (
|
||||
(typeof r.server_id === 'number' || typeof r.server_id === 'string') &&
|
||||
typeof r.score === 'number' &&
|
||||
r.analysis !== null &&
|
||||
typeof r.analysis === 'object' &&
|
||||
r.estimated_capacity !== null &&
|
||||
typeof r.estimated_capacity === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate recommendation request
|
||||
*/
|
||||
export function validateRecommendRequest(body: unknown, lang: string = 'en'): ValidationError | null {
|
||||
// Ensure lang is valid
|
||||
const validLang = ['en', 'zh', 'ja', 'ko'].includes(lang) ? lang : 'en';
|
||||
const messages = i18n[validLang];
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
return {
|
||||
error: 'Request body must be a JSON object',
|
||||
missing_fields: ['tech_stack', 'expected_users', 'use_case'],
|
||||
schema: messages.schema,
|
||||
example: messages.example
|
||||
};
|
||||
}
|
||||
|
||||
// Type guard: assert body is an object with unknown properties
|
||||
const req = body as Record<string, unknown>;
|
||||
|
||||
const missingFields: string[] = [];
|
||||
const invalidFields: { field: string; reason: string }[] = [];
|
||||
|
||||
// Check required fields
|
||||
if (!req.tech_stack) {
|
||||
missingFields.push('tech_stack');
|
||||
} else if (!Array.isArray(req.tech_stack) || req.tech_stack.length === 0) {
|
||||
invalidFields.push({ field: 'tech_stack', reason: 'must be a non-empty array of strings' });
|
||||
} else if (req.tech_stack.length > LIMITS.MAX_TECH_STACK) {
|
||||
invalidFields.push({ field: 'tech_stack', reason: `must not exceed ${LIMITS.MAX_TECH_STACK} items` });
|
||||
} else if (!req.tech_stack.every((item: unknown) =>
|
||||
typeof item === 'string' && item.length <= 50
|
||||
)) {
|
||||
invalidFields.push({ field: 'tech_stack', reason: messages.techStackItemLength || 'all items must be strings with max 50 characters' });
|
||||
}
|
||||
|
||||
if (req.expected_users === undefined) {
|
||||
missingFields.push('expected_users');
|
||||
} else if (typeof req.expected_users !== 'number' || req.expected_users < 1) {
|
||||
invalidFields.push({ field: 'expected_users', reason: 'must be a positive number' });
|
||||
} else if (req.expected_users > 10000000) {
|
||||
invalidFields.push({ field: 'expected_users', reason: 'must not exceed 10,000,000' });
|
||||
}
|
||||
|
||||
if (!req.use_case) {
|
||||
missingFields.push('use_case');
|
||||
} else if (typeof req.use_case !== 'string' || req.use_case.trim().length === 0) {
|
||||
invalidFields.push({ field: 'use_case', reason: 'must be a non-empty string' });
|
||||
} else if (req.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
|
||||
if (req.traffic_pattern !== undefined && !['steady', 'spiky', 'growing'].includes(req.traffic_pattern as string)) {
|
||||
invalidFields.push({ field: 'traffic_pattern', reason: "must be one of: 'steady', 'spiky', 'growing'" });
|
||||
}
|
||||
|
||||
if (req.budget_limit !== undefined && (typeof req.budget_limit !== 'number' || req.budget_limit < 0)) {
|
||||
invalidFields.push({ field: 'budget_limit', reason: 'must be a non-negative number' });
|
||||
}
|
||||
|
||||
// Validate lang field if provided
|
||||
if (req.lang !== undefined && !['en', 'zh', 'ja', 'ko'].includes(req.lang as string)) {
|
||||
invalidFields.push({ field: 'lang', reason: "must be one of: 'en', 'zh', 'ja', 'ko'" });
|
||||
}
|
||||
|
||||
// Validate region_preference if provided
|
||||
if (req.region_preference !== undefined) {
|
||||
if (!Array.isArray(req.region_preference)) {
|
||||
invalidFields.push({ field: 'region_preference', reason: 'must be an array of strings' });
|
||||
} else if (req.region_preference.length > LIMITS.MAX_REGION_PREFERENCE) {
|
||||
invalidFields.push({ field: 'region_preference', reason: `must not exceed ${LIMITS.MAX_REGION_PREFERENCE} items` });
|
||||
} else if (!req.region_preference.every((r: unknown) => typeof r === 'string' && r.length > 0 && r.length <= 50)) {
|
||||
invalidFields.push({ field: 'region_preference', reason: 'all items must be non-empty strings with max 50 characters' });
|
||||
}
|
||||
}
|
||||
|
||||
// Return error if any issues found
|
||||
if (missingFields.length > 0 || invalidFields.length > 0) {
|
||||
return {
|
||||
error: missingFields.length > 0 ? messages.missingFields : messages.invalidFields,
|
||||
...(missingFields.length > 0 && { missing_fields: missingFields }),
|
||||
...(invalidFields.length > 0 && { invalid_fields: invalidFields }),
|
||||
schema: messages.schema,
|
||||
example: messages.example
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user