Files
cloud-orchestrator/src/utils/cache.ts
kappa 4b00c73d96 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>
2026-01-26 03:29:12 +09:00

141 lines
3.9 KiB
TypeScript

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