Spaces:
Sleeping
Sleeping
Sasha
feat: implement caching, rate limiter, and security headers (CSP) for decoupled deploy
f0fe495 | /** | |
| * production-ready In-Memory Caching and Rate-Limiting Subsystem | |
| * Designed to protect the Supabase DB and Express server from spikes in traffic | |
| */ | |
| import dotenv from 'dotenv'; | |
| dotenv.config(); | |
| // Configuration flags | |
| const IS_CACHE_ENABLED = process.env.CACHE_ENABLED !== 'false'; | |
| const DEFAULT_TTL_MS = (parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 15) * 1000; | |
| const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 60; | |
| const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 60000; | |
| // Metrics tracker | |
| const metrics = { | |
| hits: 0, | |
| misses: 0, | |
| setCount: 0, | |
| blockedRequests: 0, | |
| }; | |
| // ========================================================================= | |
| // CACHE MANAGER CLASS | |
| // ========================================================================= | |
| class CacheManager { | |
| constructor() { | |
| this.store = new Map(); | |
| // Background garbage collection sweep every 30 seconds | |
| this.gcTimer = setInterval(() => { | |
| this.evictExpired(); | |
| }, 30000).unref(); // .unref() allows Node process to exit cleanly if running tests | |
| } | |
| set(key, value, ttlMs = DEFAULT_TTL_MS) { | |
| if (!IS_CACHE_ENABLED) return; | |
| const now = Date.now(); | |
| const expiresAt = now + ttlMs; | |
| this.store.set(key, { | |
| value, | |
| expiresAt, | |
| }); | |
| metrics.setCount++; | |
| } | |
| get(key) { | |
| if (!IS_CACHE_ENABLED) return null; | |
| const entry = this.store.get(key); | |
| if (!entry) return null; | |
| const now = Date.now(); | |
| if (now > entry.expiresAt) { | |
| this.store.delete(key); | |
| return null; | |
| } | |
| return entry; | |
| } | |
| del(key) { | |
| return this.store.delete(key); | |
| } | |
| flush() { | |
| this.store.clear(); | |
| console.log('[CacheManager] Cache flushed successfully.'); | |
| } | |
| evictExpired() { | |
| const now = Date.now(); | |
| let evictedCount = 0; | |
| for (const [key, entry] of this.store.entries()) { | |
| if (now > entry.expiresAt) { | |
| this.store.delete(key); | |
| evictedCount++; | |
| } | |
| } | |
| if (evictedCount > 0) { | |
| console.log(`[CacheManager GC] Swept and evicted ${evictedCount} expired cache keys.`); | |
| } | |
| } | |
| getStats() { | |
| const totalKeys = this.store.size; | |
| const totalRequests = metrics.hits + metrics.misses; | |
| const hitRatio = totalRequests > 0 ? ((metrics.hits / totalRequests) * 100).toFixed(1) + '%' : '0%'; | |
| return { | |
| enabled: IS_CACHE_ENABLED, | |
| totalKeys, | |
| hits: metrics.hits, | |
| misses: metrics.misses, | |
| hitRatio, | |
| setCount: metrics.setCount, | |
| blockedRequests: metrics.blockedRequests, | |
| }; | |
| } | |
| } | |
| export const cache = new CacheManager(); | |
| // ========================================================================= | |
| // RATE LIMITER CLASS | |
| // ========================================================================= | |
| class RateLimiter { | |
| constructor() { | |
| this.clients = new Map(); | |
| // Background sweeper to clear expired rate-limit buckets every 30 seconds | |
| this.gcTimer = setInterval(() => { | |
| this.evictExpired(); | |
| }, 30000).unref(); | |
| } | |
| /** | |
| * Check if client IP is within limits | |
| * @returns {object} { allowed: boolean, remaining: number, limit: number, resetTime: number } | |
| */ | |
| check(ip) { | |
| const now = Date.now(); | |
| let client = this.clients.get(ip); | |
| if (!client || now > client.resetTime) { | |
| // Create new window | |
| const resetTime = now + RATE_LIMIT_WINDOW_MS; | |
| client = { | |
| hits: 1, | |
| resetTime, | |
| }; | |
| this.clients.set(ip, client); | |
| return { | |
| allowed: true, | |
| remaining: RATE_LIMIT_MAX - 1, | |
| limit: RATE_LIMIT_MAX, | |
| resetTime, | |
| }; | |
| } | |
| client.hits++; | |
| const remaining = Math.max(0, RATE_LIMIT_MAX - client.hits); | |
| const allowed = client.hits <= RATE_LIMIT_MAX; | |
| return { | |
| allowed, | |
| remaining, | |
| limit: RATE_LIMIT_MAX, | |
| resetTime: client.resetTime, | |
| }; | |
| } | |
| evictExpired() { | |
| const now = Date.now(); | |
| let count = 0; | |
| for (const [ip, data] of this.clients.entries()) { | |
| if (now > data.resetTime) { | |
| this.clients.delete(ip); | |
| count++; | |
| } | |
| } | |
| if (count > 0) { | |
| console.log(`[RateLimiter GC] Evicted ${count} expired rate-limit records.`); | |
| } | |
| } | |
| } | |
| const rateLimiter = new RateLimiter(); | |
| // ========================================================================= | |
| // EXPRESS MIDDLEWARE | |
| // ========================================================================= | |
| /** | |
| * Express Middleware protecting route with Rate-Limiting and serving/saving Cache | |
| * @param {number} ttlSeconds Custom TTL override in seconds | |
| */ | |
| export function cacheMiddleware(ttlSeconds) { | |
| const ttlMs = ttlSeconds ? ttlSeconds * 1000 : DEFAULT_TTL_MS; | |
| return async (req, res, next) => { | |
| // 1. Bypass check if cache is disabled globally | |
| if (!IS_CACHE_ENABLED) { | |
| return next(); | |
| } | |
| // Extract real client IP (supports Express trust proxy) | |
| const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress; | |
| // 2. Perform Rate Limiting | |
| const limitStatus = rateLimiter.check(ip); | |
| // Set standard rate limit headers | |
| res.setHeader('X-RateLimit-Limit', limitStatus.limit); | |
| res.setHeader('X-RateLimit-Remaining', limitStatus.remaining); | |
| res.setHeader('X-RateLimit-Reset', Math.ceil(limitStatus.resetTime / 1000)); | |
| if (!limitStatus.allowed) { | |
| metrics.blockedRequests++; | |
| const retryAfter = Math.ceil((limitStatus.resetTime - Date.now()) / 1000); | |
| res.setHeader('Retry-After', retryAfter); | |
| console.warn(`[RateLimiter] Blocked IP: ${ip} for too many requests on path: ${req.path}`); | |
| return res.status(429).json({ | |
| error: 'Too Many Requests', | |
| message: 'Превышен лимит запросов к серверу. Пожалуйста, подождите.', | |
| retryAfter, | |
| }); | |
| } | |
| // 3. Perform Cache Lookup | |
| // Generate normalized cache key based on route path and alphabetically sorted query parameters | |
| const sortedQueries = Object.keys(req.query) | |
| .sort() | |
| .map(k => `${k}=${req.query[k]}`) | |
| .join('&'); | |
| const cacheKey = sortedQueries ? `${req.path}?${sortedQueries}` : req.path; | |
| const cachedEntry = cache.get(cacheKey); | |
| if (cachedEntry) { | |
| // Cache Hit | |
| metrics.hits++; | |
| const now = Date.now(); | |
| const remainingTtlSecs = Math.max(0, Math.ceil((cachedEntry.expiresAt - now) / 1000)); | |
| res.setHeader('X-Cache', 'HIT'); | |
| res.setHeader('X-Cache-TTL-Remaining', remainingTtlSecs); | |
| // Serve cached JSON payload | |
| return res.json(cachedEntry.value); | |
| } | |
| // Cache Miss | |
| metrics.misses++; | |
| res.setHeader('X-Cache', 'MISS'); | |
| res.setHeader('X-Cache-TTL-Remaining', Math.ceil(ttlMs / 1000)); | |
| // Intercept res.json to store the result in cache | |
| const originalJson = res.json; | |
| res.json = function (body) { | |
| // Restore original res.json first to avoid recursive loops | |
| res.json = originalJson; | |
| // Store in CacheManager | |
| cache.set(cacheKey, body, ttlMs); | |
| // Call original response | |
| return res.json(body); | |
| }; | |
| next(); | |
| }; | |
| } | |