From 49bdb4b1f1e063482c20d6e167ccfe94e345c7de Mon Sep 17 00:00:00 2001 From: Ramkumar <153575188+fuzziecoder@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:05:30 -0500 Subject: [PATCH] Add Redis-ready caching, sessions, presence, and event state APIs --- backend/README.md | 30 +++++- backend/cache.js | 254 ++++++++++++++++++++++++++++++++++++++++++++++ backend/env.js | 5 + backend/server.js | 166 ++++++++++++++++++++---------- 4 files changed, 399 insertions(+), 56 deletions(-) create mode 100644 backend/cache.js diff --git a/backend/README.md b/backend/README.md index 430e5cb..62c0b17 100644 --- a/backend/README.md +++ b/backend/README.md @@ -33,6 +33,27 @@ Server starts at `http://localhost:4000` by default. - `LOGIN_RATE_LIMIT_WINDOW_MS` - `LOGIN_RATE_LIMIT_BLOCK_MS` +### Issue #31: Redis-backed caching + session/performance primitives + +- Added optional Redis integration (`REDIS_URL`) with automatic in-memory fallback when Redis is unavailable. +- Active auth sessions are now stored in cache (token hash), and protected routes require an active session. +- Added cache-backed rate limiting primitives for login attempts (window + temporary block). +- Added short-lived cache for read-heavy endpoints: + - `GET /api/catalog` (cached) + - `GET /api/spots` (cached) +- Added real-time presence endpoints: + - `POST /api/presence/heartbeat` + - `GET /api/presence/active?spotId=...` +- Added temporary event state endpoints: + - `PUT|POST /api/events/state/:eventKey` + - `GET /api/events/state/:eventKey` +- New env vars: + - `REDIS_URL` + - `REDIS_KEY_PREFIX` + - `CACHE_DEFAULT_TTL_SECONDS` + - `PRESENCE_TTL_SECONDS` + - `EVENT_STATE_DEFAULT_TTL_SECONDS` + ### Issue #30: Secure backend data access with signed auth tokens + authorization - Login now returns an HMAC-signed bearer token (replacing predictable demo tokens). @@ -52,14 +73,19 @@ Server starts at `http://localhost:4000` by default. - `GET /api/health` - `POST /api/auth/login` -- `GET /api/catalog` +- `POST /api/auth/logout` +- `GET /api/catalog` (cached) - `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`) -- `GET /api/spots` +- `GET /api/spots` (cached) - `GET /api/orders?spotId=...&userId=...` (auth required) - `GET /api/orders/:id` (auth required) - `POST /api/orders` (auth required) - `GET /api/bills/:spotId` (admin only) - `DELETE /api/users/:userId` (admin only; removes the user and all related records) +- `POST /api/presence/heartbeat` (auth required) +- `GET /api/presence/active?spotId=...` (auth required) +- `PUT|POST /api/events/state/:eventKey` (auth required) +- `GET /api/events/state/:eventKey` (auth required) ## Example login payload diff --git a/backend/cache.js b/backend/cache.js new file mode 100644 index 0000000..3d54b77 --- /dev/null +++ b/backend/cache.js @@ -0,0 +1,254 @@ +import crypto from 'node:crypto'; + +const REDIS_URL = process.env.REDIS_URL; +const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'brocode'; +const CACHE_DEFAULT_TTL_SECONDS = Number(process.env.CACHE_DEFAULT_TTL_SECONDS || 60); +const PRESENCE_TTL_SECONDS = Number(process.env.PRESENCE_TTL_SECONDS || 60); + +const toKey = (key) => `${REDIS_KEY_PREFIX}:${key}`; + +class MemoryStore { + constructor() { + this.values = new Map(); + this.expiries = new Map(); + this.sets = new Map(); + } + + cleanupExpired(key) { + const expiresAt = this.expiries.get(key); + if (expiresAt && expiresAt <= Date.now()) { + this.values.delete(key); + this.expiries.delete(key); + return true; + } + + return false; + } + + async get(key) { + this.cleanupExpired(key); + return this.values.get(key) ?? null; + } + + async set(key, value, options = {}) { + this.values.set(key, value); + + const exSeconds = Number(options.EX || 0); + if (exSeconds > 0) { + this.expiries.set(key, Date.now() + exSeconds * 1000); + } else { + this.expiries.delete(key); + } + + return 'OK'; + } + + async del(keys) { + const arr = Array.isArray(keys) ? keys : [keys]; + arr.forEach((key) => { + this.values.delete(key); + this.expiries.delete(key); + this.sets.delete(key); + }); + return arr.length; + } + + async incr(key) { + const current = Number((await this.get(key)) || 0); + const next = current + 1; + this.values.set(key, String(next)); + return next; + } + + async sAdd(key, member) { + const current = this.sets.get(key) || new Set(); + current.add(member); + this.sets.set(key, current); + return 1; + } + + async sMembers(key) { + return [...(this.sets.get(key) || new Set())]; + } + + async sRem(key, member) { + const current = this.sets.get(key); + if (!current) { + return 0; + } + + current.delete(member); + if (current.size === 0) { + this.sets.delete(key); + } + + return 1; + } +} + +const createCacheClient = async () => { + if (!REDIS_URL) { + console.warn('⚠️ REDIS_URL not configured. Falling back to in-memory cache store.'); + return { client: new MemoryStore(), mode: 'memory' }; + } + + try { + const { createClient } = await import('redis'); + const client = createClient({ url: REDIS_URL }); + client.on('error', (error) => { + console.error('Redis client error:', error.message); + }); + await client.connect(); + console.log('✅ Connected to Redis'); + return { client, mode: 'redis' }; + } catch (error) { + console.warn(`⚠️ Redis unavailable (${error.message}). Falling back to in-memory cache store.`); + return { client: new MemoryStore(), mode: 'memory' }; + } +}; + +const parseJson = (raw) => { + if (!raw) { + return null; + } + + try { + return JSON.parse(raw); + } catch { + return null; + } +}; + +export const cache = await createCacheClient(); + +export const getOrSetJsonCache = async (key, fetcher, ttlSeconds = CACHE_DEFAULT_TTL_SECONDS) => { + const cacheKey = toKey(`cache:${key}`); + const cached = await cache.client.get(cacheKey); + if (cached) { + const parsed = parseJson(cached); + if (parsed !== null) { + return parsed; + } + } + + const freshValue = await fetcher(); + await cache.client.set(cacheKey, JSON.stringify(freshValue), { EX: ttlSeconds }); + return freshValue; +}; + +export const sessionStore = { + hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); + }, + + async setActiveSession(token, userId, ttlSeconds) { + const tokenHash = this.hashToken(token); + await cache.client.set(toKey(`session:${tokenHash}`), userId, { EX: ttlSeconds }); + }, + + async hasActiveSession(token) { + const tokenHash = this.hashToken(token); + return Boolean(await cache.client.get(toKey(`session:${tokenHash}`))); + }, + + async clearActiveSession(token) { + const tokenHash = this.hashToken(token); + await cache.client.del(toKey(`session:${tokenHash}`)); + }, +}; + +export const rateLimiter = { + async getBlockedSeconds(key) { + const blockedUntil = Number((await cache.client.get(toKey(`ratelimit:block:${key}`))) || 0); + if (!blockedUntil) { + return 0; + } + + const remainingMs = blockedUntil - Date.now(); + return remainingMs > 0 ? Math.ceil(remainingMs / 1000) : 0; + }, + + async recordFailure(key, { maxAttempts, windowMs, blockMs }) { + const attemptsKey = toKey(`ratelimit:attempts:${key}`); + const blockKey = toKey(`ratelimit:block:${key}`); + + const attempts = await cache.client.incr(attemptsKey); + if (attempts === 1) { + await cache.client.set(attemptsKey, String(attempts), { EX: Math.ceil(windowMs / 1000) }); + } + + if (attempts >= maxAttempts) { + const blockedUntil = Date.now() + blockMs; + await cache.client.set(blockKey, String(blockedUntil), { EX: Math.ceil(blockMs / 1000) }); + } + }, + + async clear(key) { + await cache.client.del([toKey(`ratelimit:attempts:${key}`), toKey(`ratelimit:block:${key}`)]); + }, +}; + +const PRESENCE_SET_KEY = toKey('presence:active-users'); + +export const presenceStore = { + async heartbeat(user, payload = {}) { + const key = toKey(`presence:user:${user.id}`); + const entry = { + userId: user.id, + username: user.username, + name: user.name, + role: user.role, + spotId: payload.spotId || null, + status: payload.status || 'online', + updatedAt: new Date().toISOString(), + }; + + await cache.client.set(key, JSON.stringify(entry), { EX: PRESENCE_TTL_SECONDS }); + await cache.client.sAdd(PRESENCE_SET_KEY, user.id); + return entry; + }, + + async listActive(spotId) { + const userIds = await cache.client.sMembers(PRESENCE_SET_KEY); + const active = []; + + for (const userId of userIds) { + const raw = await cache.client.get(toKey(`presence:user:${userId}`)); + if (!raw) { + await cache.client.sRem(PRESENCE_SET_KEY, userId); + continue; + } + + const parsed = parseJson(raw); + if (!parsed) { + continue; + } + + if (!spotId || parsed.spotId === spotId) { + active.push(parsed); + } + } + + return active; + }, +}; + +export const eventStateStore = { + async set(eventKey, state, ttlSeconds = 120) { + const key = toKey(`event-state:${eventKey}`); + const payload = { + eventKey, + state, + updatedAt: new Date().toISOString(), + ttlSeconds, + }; + + await cache.client.set(key, JSON.stringify(payload), { EX: ttlSeconds }); + return payload; + }, + + async get(eventKey) { + const key = toKey(`event-state:${eventKey}`); + return parseJson(await cache.client.get(key)); + }, +}; diff --git a/backend/env.js b/backend/env.js index 1a2890d..0403a8e 100644 --- a/backend/env.js +++ b/backend/env.js @@ -13,6 +13,11 @@ const envSchema = z.object({ LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(), LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(), LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(), + REDIS_URL: z.string().url().optional(), + REDIS_KEY_PREFIX: z.string().optional(), + CACHE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(), + PRESENCE_TTL_SECONDS: z.string().regex(/^\d+$/).optional(), + EVENT_STATE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(), }); const result = envSchema.safeParse(process.env); diff --git a/backend/server.js b/backend/server.js index 5d5532a..7a0c0da 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,6 +2,7 @@ import { createServer } from 'node:http'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { URL } from 'node:url'; import { database, dbPath } from './db.js'; +import { cache, eventStateStore, getOrSetJsonCache, presenceStore, rateLimiter, sessionStore } from './cache.js'; import "./env.js"; const port = Number(process.env.PORT || 4000); @@ -11,8 +12,8 @@ const LOGIN_RATE_LIMIT_WINDOW_MS = Number(process.env.LOGIN_RATE_LIMIT_WINDOW_MS const LOGIN_RATE_LIMIT_BLOCK_MS = Number(process.env.LOGIN_RATE_LIMIT_BLOCK_MS || 15 * 60 * 1000); const AUTH_TOKEN_SECRET = process.env.AUTH_TOKEN_SECRET || 'brocode-dev-secret-change-me'; const AUTH_TOKEN_TTL_SECONDS = Number(process.env.AUTH_TOKEN_TTL_SECONDS || 60 * 60 * 12); +const EVENT_STATE_DEFAULT_TTL_SECONDS = Number(process.env.EVENT_STATE_DEFAULT_TTL_SECONDS || 120); const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN || '*'; -const loginAttempts = new Map(); const getLoginKey = (req, username) => { const forwardedFor = req.headers['x-forwarded-for']; @@ -25,34 +26,6 @@ const getLoginKey = (req, username) => { return `${remoteIp}:${username}`; }; -const getRateLimitState = (key) => { - const now = Date.now(); - const existing = loginAttempts.get(key); - - if (!existing) { - const state = { count: 0, windowStart: now, blockedUntil: 0 }; - loginAttempts.set(key, state); - return state; - } - - if (existing.blockedUntil > 0 && existing.blockedUntil <= now) { - existing.count = 0; - existing.windowStart = now; - existing.blockedUntil = 0; - } - - if (now - existing.windowStart > LOGIN_RATE_LIMIT_WINDOW_MS) { - existing.count = 0; - existing.windowStart = now; - } - - return existing; -}; - -const clearRateLimitState = (key) => { - loginAttempts.delete(key); -}; - const parseBearerToken = (authHeader) => { if (typeof authHeader !== 'string') { return null; @@ -112,12 +85,17 @@ const verifyAuthToken = (token) => { } }; -const getUserFromAuthHeader = (authHeader) => { +const getUserFromAuthHeader = async (authHeader) => { const token = parseBearerToken(authHeader); if (!token) { return null; } + const hasActiveSession = await sessionStore.hasActiveSession(token); + if (!hasActiveSession) { + return null; + } + const verifiedPayload = verifyAuthToken(token); if (!verifiedPayload) { return null; @@ -126,16 +104,6 @@ const getUserFromAuthHeader = (authHeader) => { return database.getUserById(verifiedPayload.sub); }; -const recordFailedLoginAttempt = (key) => { - const now = Date.now(); - const state = getRateLimitState(key); - state.count += 1; - - if (state.count >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS) { - state.blockedUntil = now + LOGIN_RATE_LIMIT_BLOCK_MS; - } -}; - const sendJson = (res, statusCode, body) => { res.writeHead(statusCode, { 'Content-Type': 'application/json', @@ -195,10 +163,8 @@ const server = createServer(async (req, res) => { } const loginKey = getLoginKey(req, username); - const rateLimitState = getRateLimitState(loginKey); - const now = Date.now(); - if (rateLimitState.blockedUntil > now) { - const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000); + const retryAfterSeconds = await rateLimiter.getBlockedSeconds(loginKey); + if (retryAfterSeconds > 0) { sendJson(res, 429, { error: 'Too many failed login attempts. Please try again later.', retryAfterSeconds, @@ -209,14 +175,21 @@ const server = createServer(async (req, res) => { const user = database.getUserByCredentials(username, password); if (!user) { - recordFailedLoginAttempt(loginKey); + await rateLimiter.recordFailure(loginKey, { + maxAttempts: LOGIN_RATE_LIMIT_MAX_ATTEMPTS, + windowMs: LOGIN_RATE_LIMIT_WINDOW_MS, + blockMs: LOGIN_RATE_LIMIT_BLOCK_MS, + }); sendJson(res, 401, { error: 'invalid credentials' }); return; } - clearRateLimitState(loginKey); + await rateLimiter.clear(loginKey); + + const token = generateAuthToken(user); + await sessionStore.setActiveSession(token, user.id, AUTH_TOKEN_TTL_SECONDS); - sendJson(res, 200, { token: generateAuthToken(user), user }); + sendJson(res, 200, { token, user }); return; } catch (error) { sendJson(res, 400, { error: error.message }); @@ -224,8 +197,21 @@ const server = createServer(async (req, res) => { } } + if (method === 'POST' && path === '/api/auth/logout') { + const token = parseBearerToken(req.headers.authorization); + if (!token) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + await sessionStore.clearActiveSession(token); + sendJson(res, 200, { success: true }); + return; + } + if (method === 'GET' && path === '/api/catalog') { - sendJson(res, 200, database.getCatalog()); + const catalog = await getOrSetJsonCache('catalog', async () => database.getCatalog()); + sendJson(res, 200, catalog); return; } @@ -244,12 +230,13 @@ const server = createServer(async (req, res) => { } if (method === 'GET' && path === '/api/spots') { - sendJson(res, 200, database.getSpots()); + const spots = await getOrSetJsonCache('spots', async () => database.getSpots()); + sendJson(res, 200, spots); return; } if (method === 'GET' && path === '/api/orders') { - const authedUser = getUserFromAuthHeader(req.headers.authorization); + const authedUser = await getUserFromAuthHeader(req.headers.authorization); if (!authedUser) { sendJson(res, 401, { error: 'Unauthorized' }); return; @@ -272,7 +259,7 @@ const server = createServer(async (req, res) => { if (method === 'GET' && path.startsWith('/api/orders/')) { const orderId = path.replace('/api/orders/', ''); - const authedUser = getUserFromAuthHeader(req.headers.authorization); + const authedUser = await getUserFromAuthHeader(req.headers.authorization); if (!authedUser) { sendJson(res, 401, { error: 'Unauthorized' }); return; @@ -297,7 +284,7 @@ const server = createServer(async (req, res) => { if (method === 'POST' && path === '/api/orders') { try { - const authedUser = getUserFromAuthHeader(req.headers.authorization); + const authedUser = await getUserFromAuthHeader(req.headers.authorization); if (!authedUser) { sendJson(res, 401, { error: 'Unauthorized' }); return; @@ -339,7 +326,7 @@ const server = createServer(async (req, res) => { } if (method === 'GET' && path.startsWith('/api/bills/')) { - const authedUser = getUserFromAuthHeader(req.headers.authorization); + const authedUser = await getUserFromAuthHeader(req.headers.authorization); if (!authedUser || authedUser.role !== 'admin') { sendJson(res, 403, { error: 'Forbidden' }); return; @@ -352,7 +339,7 @@ const server = createServer(async (req, res) => { } if (method === 'DELETE' && path.startsWith('/api/users/')) { - const authedUser = getUserFromAuthHeader(req.headers.authorization); + const authedUser = await getUserFromAuthHeader(req.headers.authorization); if (!authedUser || authedUser.role !== 'admin') { sendJson(res, 403, { error: 'Forbidden' }); return; @@ -375,10 +362,81 @@ const server = createServer(async (req, res) => { return; } + if (method === 'POST' && path === '/api/presence/heartbeat') { + const authedUser = await getUserFromAuthHeader(req.headers.authorization); + if (!authedUser) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + const payload = await readBody(req); + const presence = await presenceStore.heartbeat(authedUser, payload); + sendJson(res, 200, presence); + return; + } + + if (method === 'GET' && path === '/api/presence/active') { + const authedUser = await getUserFromAuthHeader(req.headers.authorization); + if (!authedUser) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + const spotId = parsedUrl.searchParams.get('spotId'); + const activeUsers = await presenceStore.listActive(spotId); + sendJson(res, 200, { activeUsers, count: activeUsers.length }); + return; + } + + if ((method === 'PUT' || method === 'POST') && path.startsWith('/api/events/state/')) { + const authedUser = await getUserFromAuthHeader(req.headers.authorization); + if (!authedUser) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + const eventKey = path.replace('/api/events/state/', '').trim(); + if (!eventKey) { + sendJson(res, 400, { error: 'eventKey is required' }); + return; + } + + const { state, ttlSeconds } = await readBody(req); + if (typeof state === 'undefined') { + sendJson(res, 400, { error: 'state is required' }); + return; + } + + const effectiveTtl = Number(ttlSeconds || EVENT_STATE_DEFAULT_TTL_SECONDS); + const saved = await eventStateStore.set(eventKey, state, effectiveTtl); + sendJson(res, 200, saved); + return; + } + + if (method === 'GET' && path.startsWith('/api/events/state/')) { + const authedUser = await getUserFromAuthHeader(req.headers.authorization); + if (!authedUser) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + + const eventKey = path.replace('/api/events/state/', '').trim(); + const eventState = await eventStateStore.get(eventKey); + + if (!eventState) { + sendJson(res, 404, { error: `No temporary state found for event: ${eventKey}` }); + return; + } + + sendJson(res, 200, eventState); + return; + } + sendJson(res, 404, { error: 'Route not found' }); }); server.listen(port, () => { console.log(`Backend API running on http://localhost:${port}`); console.log(`Using local database at: ${dbPath}`); + console.log(`Cache mode: ${cache.mode}`); });