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:
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user