Skip to content

Commit 5445543

Browse files
authored
Merge branch 'main' into codex/implement-security-and-deployment-features
2 parents 382fed6 + f5a9e41 commit 5445543

8 files changed

Lines changed: 984 additions & 16 deletions

File tree

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,18 @@ VITE_GEMINI_API_KEY=your_gemini_api_key_here
99
# These are only used when mockApi.ts is active
1010
VITE_ADMIN_PASSWORD=your_admin_password_here
1111
VITE_USER_PASSWORD=your_user_password_here
12+
13+
# Backend server
14+
PORT=4000
15+
CORS_ALLOW_ORIGIN=*
16+
AUTH_TOKEN_SECRET=replace_with_a_long_random_secret
17+
AUTH_TOKEN_TTL_SECONDS=43200
18+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
19+
LOGIN_RATE_LIMIT_WINDOW_MS=900000
20+
LOGIN_RATE_LIMIT_BLOCK_MS=900000
21+
22+
# BullMQ + Redis
23+
REDIS_HOST=127.0.0.1
24+
REDIS_PORT=6379
25+
REDIS_PASSWORD=
26+
EVENT_REMINDER_BEFORE_HOURS=2

backend/README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,76 @@ Legacy plaintext records auto-migrate to hashed values at successful login.
5656
- New orders are validated against DB data (known `spotId`, `userId`, `productId`) and item pricing is always derived from catalog prices in the database.
5757

5858
## Deployment (Render / Railway / AWS EC2)
59+
### Issue #31: Redis-backed caching + session/performance primitives
60+
61+
- Added optional Redis integration (`REDIS_URL`) with automatic in-memory fallback when Redis is unavailable.
62+
- Active auth sessions are now stored in cache (token hash), and protected routes require an active session.
63+
- Added cache-backed rate limiting primitives for login attempts (window + temporary block).
64+
- Added short-lived cache for read-heavy endpoints:
65+
- `GET /api/catalog` (cached)
66+
- `GET /api/spots` (cached)
67+
- Added real-time presence endpoints:
68+
- `POST /api/presence/heartbeat`
69+
- `GET /api/presence/active?spotId=...`
70+
- Added temporary event state endpoints:
71+
- `PUT|POST /api/events/state/:eventKey`
72+
- `GET /api/events/state/:eventKey`
73+
- New env vars:
74+
- `REDIS_URL`
75+
- `REDIS_KEY_PREFIX`
76+
- `CACHE_DEFAULT_TTL_SECONDS`
77+
- `PRESENCE_TTL_SECONDS`
78+
- `EVENT_STATE_DEFAULT_TTL_SECONDS`
79+
80+
### Issue #30: Secure backend data access with signed auth tokens + authorization
5981

6082
See [`backend/deployment.md`](./deployment.md) for step-by-step deployment options and env setup for:
6183

6284
- Backend hosting: **Render**, **Railway**, **AWS EC2**
6385
- PostgreSQL: **Supabase** or **Neon**
6486
- Redis: **Upstash**
6587
- File storage: **AWS S3** or **Cloudinary**
88+
### Background jobs (BullMQ + Redis)
89+
90+
- The backend initializes BullMQ queues for:
91+
- email notification jobs (`email-notifications`) when a new order is created.
92+
- scheduled reminder jobs (`spot-reminders`) for upcoming spots/events.
93+
- recurring cleanup jobs (`expired-spot-cleanup`) for expired events.
94+
- Redis connection settings:
95+
- `REDIS_HOST` (default `127.0.0.1`)
96+
- `REDIS_PORT` (default `6379`)
97+
- `REDIS_PASSWORD` (optional)
98+
- Reminder timing:
99+
- `EVENT_REMINDER_BEFORE_HOURS` (default `2`)
100+
- If BullMQ or Redis dependencies are unavailable, backend continues to run with jobs disabled and logs a warning.
101+
102+
### API Documentation (Swagger/OpenAPI)
103+
104+
- OpenAPI JSON is available at `GET /api/docs/openapi.json`.
105+
- Swagger UI is available at `GET /api/docs`.
66106

67107
## Available endpoints
68108

69109
- `GET /api/health`
70110
- `POST /api/auth/login`
111+
- `GET /api/docs`
112+
- `GET /api/docs/openapi.json`
71113
- `GET /api/catalog`
114+
- `POST /api/auth/logout`
115+
- `GET /api/catalog` (cached)
72116
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
73-
- `GET /api/spots`
117+
- `GET /api/spots` (cached)
74118
- `GET /api/orders?spotId=...&userId=...` (auth required)
75119
- `GET /api/orders/:id` (auth required)
76120
- `POST /api/orders` (auth required)
77121
- `GET /api/bills/:spotId` (admin only)
78122
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
123+
- `POST /api/jobs/reminders/run` (admin only; manually queue reminder jobs)
124+
- `POST /api/jobs/cleanup/run` (admin only; manually queue expired-event cleanup)
125+
- `POST /api/presence/heartbeat` (auth required)
126+
- `GET /api/presence/active?spotId=...` (auth required)
127+
- `PUT|POST /api/events/state/:eventKey` (auth required)
128+
- `GET /api/events/state/:eventKey` (auth required)
79129

80130
## Example login payload
81131

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+
};

0 commit comments

Comments
 (0)