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

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
};
}