Skip to content

Commit 3eea30a

Browse files
authored
Merge pull request #53 from fuzziecoder/codex/implement-redis-caching-features
Add Redis-ready caching, sessions, presence, and event-state APIs
2 parents e5fa696 + 49bdb4b commit 3eea30a

4 files changed

Lines changed: 399 additions & 56 deletions

File tree

backend/README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,27 @@ Server starts at `http://localhost:4000` by default.
3333
- `LOGIN_RATE_LIMIT_WINDOW_MS`
3434
- `LOGIN_RATE_LIMIT_BLOCK_MS`
3535

36+
### Issue #31: Redis-backed caching + session/performance primitives
37+
38+
- Added optional Redis integration (`REDIS_URL`) with automatic in-memory fallback when Redis is unavailable.
39+
- Active auth sessions are now stored in cache (token hash), and protected routes require an active session.
40+
- Added cache-backed rate limiting primitives for login attempts (window + temporary block).
41+
- Added short-lived cache for read-heavy endpoints:
42+
- `GET /api/catalog` (cached)
43+
- `GET /api/spots` (cached)
44+
- Added real-time presence endpoints:
45+
- `POST /api/presence/heartbeat`
46+
- `GET /api/presence/active?spotId=...`
47+
- Added temporary event state endpoints:
48+
- `PUT|POST /api/events/state/:eventKey`
49+
- `GET /api/events/state/:eventKey`
50+
- New env vars:
51+
- `REDIS_URL`
52+
- `REDIS_KEY_PREFIX`
53+
- `CACHE_DEFAULT_TTL_SECONDS`
54+
- `PRESENCE_TTL_SECONDS`
55+
- `EVENT_STATE_DEFAULT_TTL_SECONDS`
56+
3657
### Issue #30: Secure backend data access with signed auth tokens + authorization
3758

3859
- Login now returns an HMAC-signed bearer token (replacing predictable demo tokens).
@@ -52,14 +73,19 @@ Server starts at `http://localhost:4000` by default.
5273

5374
- `GET /api/health`
5475
- `POST /api/auth/login`
55-
- `GET /api/catalog`
76+
- `POST /api/auth/logout`
77+
- `GET /api/catalog` (cached)
5678
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
57-
- `GET /api/spots`
79+
- `GET /api/spots` (cached)
5880
- `GET /api/orders?spotId=...&userId=...` (auth required)
5981
- `GET /api/orders/:id` (auth required)
6082
- `POST /api/orders` (auth required)
6183
- `GET /api/bills/:spotId` (admin only)
6284
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
85+
- `POST /api/presence/heartbeat` (auth required)
86+
- `GET /api/presence/active?spotId=...` (auth required)
87+
- `PUT|POST /api/events/state/:eventKey` (auth required)
88+
- `GET /api/events/state/:eventKey` (auth required)
6389

6490
## Example login payload
6591

backend/cache.js

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import crypto from 'node:crypto';
2+
3+
const REDIS_URL = process.env.REDIS_URL;
4+
const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'brocode';
5+
const CACHE_DEFAULT_TTL_SECONDS = Number(process.env.CACHE_DEFAULT_TTL_SECONDS || 60);
6+
const PRESENCE_TTL_SECONDS = Number(process.env.PRESENCE_TTL_SECONDS || 60);
7+
8+
const toKey = (key) => `${REDIS_KEY_PREFIX}:${key}`;
9+
10+
class MemoryStore {
11+
constructor() {
12+
this.values = new Map();
13+
this.expiries = new Map();
14+
this.sets = new Map();
15+
}
16+
17+
cleanupExpired(key) {
18+
const expiresAt = this.expiries.get(key);
19+
if (expiresAt && expiresAt <= Date.now()) {
20+
this.values.delete(key);
21+
this.expiries.delete(key);
22+
return true;
23+
}
24+
25+
return false;
26+
}
27+
28+
async get(key) {
29+
this.cleanupExpired(key);
30+
return this.values.get(key) ?? null;
31+
}
32+
33+
async set(key, value, options = {}) {
34+
this.values.set(key, value);
35+
36+
const exSeconds = Number(options.EX || 0);
37+
if (exSeconds > 0) {
38+
this.expiries.set(key, Date.now() + exSeconds * 1000);
39+
} else {
40+
this.expiries.delete(key);
41+
}
42+
43+
return 'OK';
44+
}
45+
46+
async del(keys) {
47+
const arr = Array.isArray(keys) ? keys : [keys];
48+
arr.forEach((key) => {
49+
this.values.delete(key);
50+
this.expiries.delete(key);
51+
this.sets.delete(key);
52+
});
53+
return arr.length;
54+
}
55+
56+
async incr(key) {
57+
const current = Number((await this.get(key)) || 0);
58+
const next = current + 1;
59+
this.values.set(key, String(next));
60+
return next;
61+
}
62+
63+
async sAdd(key, member) {
64+
const current = this.sets.get(key) || new Set();
65+
current.add(member);
66+
this.sets.set(key, current);
67+
return 1;
68+
}
69+
70+
async sMembers(key) {
71+
return [...(this.sets.get(key) || new Set())];
72+
}
73+
74+
async sRem(key, member) {
75+
const current = this.sets.get(key);
76+
if (!current) {
77+
return 0;
78+
}
79+
80+
current.delete(member);
81+
if (current.size === 0) {
82+
this.sets.delete(key);
83+
}
84+
85+
return 1;
86+
}
87+
}
88+
89+
const createCacheClient = async () => {
90+
if (!REDIS_URL) {
91+
console.warn('⚠️ REDIS_URL not configured. Falling back to in-memory cache store.');
92+
return { client: new MemoryStore(), mode: 'memory' };
93+
}
94+
95+
try {
96+
const { createClient } = await import('redis');
97+
const client = createClient({ url: REDIS_URL });
98+
client.on('error', (error) => {
99+
console.error('Redis client error:', error.message);
100+
});
101+
await client.connect();
102+
console.log('✅ Connected to Redis');
103+
return { client, mode: 'redis' };
104+
} catch (error) {
105+
console.warn(`⚠️ Redis unavailable (${error.message}). Falling back to in-memory cache store.`);
106+
return { client: new MemoryStore(), mode: 'memory' };
107+
}
108+
};
109+
110+
const parseJson = (raw) => {
111+
if (!raw) {
112+
return null;
113+
}
114+
115+
try {
116+
return JSON.parse(raw);
117+
} catch {
118+
return null;
119+
}
120+
};
121+
122+
export const cache = await createCacheClient();
123+
124+
export const getOrSetJsonCache = async (key, fetcher, ttlSeconds = CACHE_DEFAULT_TTL_SECONDS) => {
125+
const cacheKey = toKey(`cache:${key}`);
126+
const cached = await cache.client.get(cacheKey);
127+
if (cached) {
128+
const parsed = parseJson(cached);
129+
if (parsed !== null) {
130+
return parsed;
131+
}
132+
}
133+
134+
const freshValue = await fetcher();
135+
await cache.client.set(cacheKey, JSON.stringify(freshValue), { EX: ttlSeconds });
136+
return freshValue;
137+
};
138+
139+
export const sessionStore = {
140+
hashToken(token) {
141+
return crypto.createHash('sha256').update(token).digest('hex');
142+
},
143+
144+
async setActiveSession(token, userId, ttlSeconds) {
145+
const tokenHash = this.hashToken(token);
146+
await cache.client.set(toKey(`session:${tokenHash}`), userId, { EX: ttlSeconds });
147+
},
148+
149+
async hasActiveSession(token) {
150+
const tokenHash = this.hashToken(token);
151+
return Boolean(await cache.client.get(toKey(`session:${tokenHash}`)));
152+
},
153+
154+
async clearActiveSession(token) {
155+
const tokenHash = this.hashToken(token);
156+
await cache.client.del(toKey(`session:${tokenHash}`));
157+
},
158+
};
159+
160+
export const rateLimiter = {
161+
async getBlockedSeconds(key) {
162+
const blockedUntil = Number((await cache.client.get(toKey(`ratelimit:block:${key}`))) || 0);
163+
if (!blockedUntil) {
164+
return 0;
165+
}
166+
167+
const remainingMs = blockedUntil - Date.now();
168+
return remainingMs > 0 ? Math.ceil(remainingMs / 1000) : 0;
169+
},
170+
171+
async recordFailure(key, { maxAttempts, windowMs, blockMs }) {
172+
const attemptsKey = toKey(`ratelimit:attempts:${key}`);
173+
const blockKey = toKey(`ratelimit:block:${key}`);
174+
175+
const attempts = await cache.client.incr(attemptsKey);
176+
if (attempts === 1) {
177+
await cache.client.set(attemptsKey, String(attempts), { EX: Math.ceil(windowMs / 1000) });
178+
}
179+
180+
if (attempts >= maxAttempts) {
181+
const blockedUntil = Date.now() + blockMs;
182+
await cache.client.set(blockKey, String(blockedUntil), { EX: Math.ceil(blockMs / 1000) });
183+
}
184+
},
185+
186+
async clear(key) {
187+
await cache.client.del([toKey(`ratelimit:attempts:${key}`), toKey(`ratelimit:block:${key}`)]);
188+
},
189+
};
190+
191+
const PRESENCE_SET_KEY = toKey('presence:active-users');
192+
193+
export const presenceStore = {
194+
async heartbeat(user, payload = {}) {
195+
const key = toKey(`presence:user:${user.id}`);
196+
const entry = {
197+
userId: user.id,
198+
username: user.username,
199+
name: user.name,
200+
role: user.role,
201+
spotId: payload.spotId || null,
202+
status: payload.status || 'online',
203+
updatedAt: new Date().toISOString(),
204+
};
205+
206+
await cache.client.set(key, JSON.stringify(entry), { EX: PRESENCE_TTL_SECONDS });
207+
await cache.client.sAdd(PRESENCE_SET_KEY, user.id);
208+
return entry;
209+
},
210+
211+
async listActive(spotId) {
212+
const userIds = await cache.client.sMembers(PRESENCE_SET_KEY);
213+
const active = [];
214+
215+
for (const userId of userIds) {
216+
const raw = await cache.client.get(toKey(`presence:user:${userId}`));
217+
if (!raw) {
218+
await cache.client.sRem(PRESENCE_SET_KEY, userId);
219+
continue;
220+
}
221+
222+
const parsed = parseJson(raw);
223+
if (!parsed) {
224+
continue;
225+
}
226+
227+
if (!spotId || parsed.spotId === spotId) {
228+
active.push(parsed);
229+
}
230+
}
231+
232+
return active;
233+
},
234+
};
235+
236+
export const eventStateStore = {
237+
async set(eventKey, state, ttlSeconds = 120) {
238+
const key = toKey(`event-state:${eventKey}`);
239+
const payload = {
240+
eventKey,
241+
state,
242+
updatedAt: new Date().toISOString(),
243+
ttlSeconds,
244+
};
245+
246+
await cache.client.set(key, JSON.stringify(payload), { EX: ttlSeconds });
247+
return payload;
248+
},
249+
250+
async get(eventKey) {
251+
const key = toKey(`event-state:${eventKey}`);
252+
return parseJson(await cache.client.get(key));
253+
},
254+
};

backend/env.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const envSchema = z.object({
1313
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
1414
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
1515
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
16+
REDIS_URL: z.string().url().optional(),
17+
REDIS_KEY_PREFIX: z.string().optional(),
18+
CACHE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
19+
PRESENCE_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
20+
EVENT_STATE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
1621
});
1722

1823
const result = envSchema.safeParse(process.env);

0 commit comments

Comments
 (0)