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:
kappa
2026-01-26 03:29:12 +09:00
parent 411cde4801
commit 4b00c73d96
20 changed files with 3253 additions and 1537 deletions

39
src/utils/ai.ts Normal file
View 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
View 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
View 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 };
}
}

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* 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
View 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
View 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;
}