## 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>
141 lines
3.9 KiB
TypeScript
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 };
|
|
}
|
|
}
|