Skip to content

Commit b76005c

Browse files
authored
Merge branch 'main' into codex/fix-user-deletion-handling-in-backend
2 parents a26ec55 + cfb3d8e commit b76005c

4 files changed

Lines changed: 226 additions & 77 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ dist-ssr
2727
*.njsproj
2828
*.sln
2929
*.sw?
30+
31+
# Local backend datastore
32+
backend/data/

backend/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ Server starts at `http://localhost:4000` by default.
2424
- Passwords are stored as salted `scrypt` hashes (not plaintext).
2525
- Legacy plaintext user passwords are auto-migrated to hashed values on successful login.
2626

27+
### Issue #29: Protect login endpoint from brute-force attempts
28+
29+
- Login is now rate-limited per `IP + username` key.
30+
- Defaults: 5 failed attempts within 15 minutes triggers a 15 minute temporary block (`429`).
31+
- Configure via env vars:
32+
- `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`
33+
- `LOGIN_RATE_LIMIT_WINDOW_MS`
34+
- `LOGIN_RATE_LIMIT_BLOCK_MS`
35+
36+
2737
## Available endpoints
2838

2939
- `GET /api/health`

backend/db.js

Lines changed: 122 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { existsSync, mkdirSync } from 'node:fs';
1+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
import { dirname, resolve } from 'node:path';
33
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
4-
import { DatabaseSync } from 'node:sqlite';
54

65
const defaultDbPath = resolve(process.cwd(), 'backend', 'data', 'brocode.sqlite');
76
const dbPath = process.env.BROCODE_DB_PATH ? resolve(process.env.BROCODE_DB_PATH) : defaultDbPath;
@@ -11,10 +10,6 @@ if (!existsSync(dbDirectory)) {
1110
mkdirSync(dbDirectory, { recursive: true });
1211
}
1312

14-
const db = new DatabaseSync(dbPath);
15-
db.exec('PRAGMA journal_mode = WAL;');
16-
db.exec('PRAGMA foreign_keys = ON;');
17-
1813
const HASH_PREFIX = 'scrypt$';
1914
const SCRYPT_KEY_LENGTH = 64;
2015

@@ -112,6 +107,79 @@ if (!hasUsers) {
112107
VALUES (?, ?, ?, ?, ?, ?)`
113108
).run('ord-1', 'd-1', 'Brocode Beer', 2, 180, 360);
114109
}
110+
const seedData = () => ({
111+
users: [
112+
{ id: 'u-1', username: 'brocode', password: hashPassword('changeme'), name: 'Ram', role: 'admin' },
113+
{ id: 'u-2', username: 'dhanush', password: hashPassword('changeme'), name: 'Dhanush', role: 'user' },
114+
],
115+
spots: [
116+
{
117+
id: 'spot-2025-07-26',
118+
location: 'Attibele Toll Plaza',
119+
date: '2025-07-26T10:00:00.000Z',
120+
host_user_id: 'u-1',
121+
},
122+
],
123+
catalog_items: [
124+
{ id: 'd-1', category: 'drinks', name: 'Brocode Beer', price: 180 },
125+
{ id: 'd-2', category: 'drinks', name: 'Kingfisher Beer', price: 170 },
126+
{ id: 'f-1', category: 'food', name: 'Beef Biriyani', price: 220 },
127+
{ id: 'f-2', category: 'food', name: 'Parotta', price: 30 },
128+
{ id: 'c-1', category: 'cigarettes', name: 'Marlboro', price: 25 },
129+
{ id: 'c-2', category: 'cigarettes', name: 'Classic', price: 20 },
130+
],
131+
orders: [
132+
{
133+
id: 'ord-1',
134+
spot_id: 'spot-2025-07-26',
135+
user_id: 'u-2',
136+
total_amount: 360,
137+
created_at: '2025-07-26T10:30:00.000Z',
138+
},
139+
],
140+
order_items: [
141+
{
142+
id: 1,
143+
order_id: 'ord-1',
144+
product_id: 'd-1',
145+
name: 'Brocode Beer',
146+
quantity: 2,
147+
unit_price: 180,
148+
total: 360,
149+
},
150+
],
151+
next_order_item_id: 2,
152+
});
153+
154+
const loadData = () => {
155+
if (!existsSync(dbPath)) {
156+
const initial = seedData();
157+
writeFileSync(dbPath, JSON.stringify(initial, null, 2));
158+
return initial;
159+
}
160+
161+
try {
162+
const parsed = JSON.parse(readFileSync(dbPath, 'utf-8'));
163+
return {
164+
users: parsed.users || [],
165+
spots: parsed.spots || [],
166+
catalog_items: parsed.catalog_items || [],
167+
orders: parsed.orders || [],
168+
order_items: parsed.order_items || [],
169+
next_order_item_id: parsed.next_order_item_id || 1,
170+
};
171+
} catch {
172+
const initial = seedData();
173+
writeFileSync(dbPath, JSON.stringify(initial, null, 2));
174+
return initial;
175+
}
176+
};
177+
178+
const state = loadData();
179+
180+
const persist = () => {
181+
writeFileSync(dbPath, JSON.stringify(state, null, 2));
182+
};
115183

116184
const mapOrder = (order, items) => ({
117185
id: order.id,
@@ -166,14 +234,15 @@ const deleteSpotsByHostUserIdStatement = db.prepare('DELETE FROM spots WHERE hos
166234

167235
export const database = {
168236
getUserByCredentials(username, password) {
169-
const user = getUserByUsernameStatement.get(username);
237+
const user = state.users.find((entry) => entry.username === username);
170238

171239
if (!user || !verifyPassword(password, user.password)) {
172240
return null;
173241
}
174242

175243
if (!user.password.startsWith(HASH_PREFIX)) {
176-
updateUserPasswordStatement.run(hashPassword(password), user.id);
244+
user.password = hashPassword(password);
245+
persist();
177246
}
178247

179248
return {
@@ -185,8 +254,7 @@ export const database = {
185254
},
186255

187256
getCatalog() {
188-
const rows = db.prepare('SELECT id, category, name, price FROM catalog_items ORDER BY category, id').all();
189-
return rows.reduce(
257+
return state.catalog_items.reduce(
190258
(acc, row) => {
191259
if (!acc[row.category]) {
192260
acc[row.category] = [];
@@ -204,52 +272,35 @@ export const database = {
204272
);
205273
},
206274

207-
getCatalogCategory(category) {
208-
const rows = db.prepare('SELECT id, name, price FROM catalog_items WHERE category = ? ORDER BY id').all(category);
209-
return rows;
210-
},
211-
212275
userExists(userId) {
213-
return Boolean(userExistsStatement.get(userId));
276+
return state.users.some((user) => user.id === userId);
214277
},
215278

216279
spotExists(spotId) {
217-
return Boolean(spotExistsStatement.get(spotId));
280+
return state.spots.some((spot) => spot.id === spotId);
218281
},
219282

220283
getSpots() {
221-
return db
222-
.prepare('SELECT id, location, date, host_user_id AS hostUserId FROM spots ORDER BY date DESC')
223-
.all();
284+
return state.spots
285+
.map((spot) => ({
286+
id: spot.id,
287+
location: spot.location,
288+
date: spot.date,
289+
hostUserId: spot.host_user_id,
290+
}))
291+
.sort((a, b) => b.date.localeCompare(a.date));
224292
},
225293

226294
getOrders({ spotId, userId }) {
227-
const conditions = [];
228-
const values = [];
229-
230-
if (spotId) {
231-
conditions.push('spot_id = ?');
232-
values.push(spotId);
233-
}
234-
235-
if (userId) {
236-
conditions.push('user_id = ?');
237-
values.push(userId);
238-
}
239-
240-
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
241-
const orders = db
242-
.prepare(
243-
`SELECT id, spot_id, user_id, total_amount, created_at
244-
FROM orders
245-
${whereClause}
246-
ORDER BY created_at DESC`
247-
)
248-
.all(...values);
249-
250-
const itemsByOrderId = fetchOrderItemsByOrderIds(orders.map((order) => order.id));
251-
252-
return orders.map((order) => mapOrder(order, itemsByOrderId.get(order.id) || []));
295+
const orders = state.orders
296+
.filter((order) => !spotId || order.spot_id === spotId)
297+
.filter((order) => !userId || order.user_id === userId)
298+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
299+
300+
return orders.map((order) => {
301+
const orderItems = state.order_items.filter((item) => item.order_id === order.id);
302+
return mapOrder(order, orderItems);
303+
});
253304
},
254305

255306
createOrder({ spotId, userId, items }) {
@@ -259,7 +310,7 @@ export const database = {
259310
throw new Error('Each order item must include productId and a positive integer quantity');
260311
}
261312

262-
const catalogItem = getCatalogItemByIdStatement.get(item.productId);
313+
const catalogItem = state.catalog_items.find((entry) => entry.id === item.productId);
263314
if (!catalogItem) {
264315
throw new Error(`Unknown productId: ${item.productId}`);
265316
}
@@ -277,24 +328,27 @@ export const database = {
277328
const orderId = randomUUID();
278329
const createdAt = new Date().toISOString();
279330

280-
const insertOrder = db.prepare(
281-
'INSERT INTO orders (id, spot_id, user_id, total_amount, created_at) VALUES (?, ?, ?, ?, ?)'
282-
);
283-
const insertOrderItem = db.prepare(
284-
'INSERT INTO order_items (order_id, product_id, name, quantity, unit_price, total) VALUES (?, ?, ?, ?, ?, ?)'
285-
);
331+
state.orders.push({
332+
id: orderId,
333+
spot_id: spotId,
334+
user_id: userId,
335+
total_amount: totalAmount,
336+
created_at: createdAt,
337+
});
286338

287-
db.exec('BEGIN');
288-
try {
289-
insertOrder.run(orderId, spotId, userId, totalAmount, createdAt);
290-
parsedItems.forEach((item) => {
291-
insertOrderItem.run(orderId, item.productId, item.name, item.quantity, item.unitPrice, item.total);
339+
parsedItems.forEach((item) => {
340+
state.order_items.push({
341+
id: state.next_order_item_id++,
342+
order_id: orderId,
343+
product_id: item.productId,
344+
name: item.name,
345+
quantity: item.quantity,
346+
unit_price: item.unitPrice,
347+
total: item.total,
292348
});
293-
db.exec('COMMIT');
294-
} catch (error) {
295-
db.exec('ROLLBACK');
296-
throw error;
297-
}
349+
});
350+
351+
persist();
298352

299353
return {
300354
id: orderId,
@@ -322,28 +376,19 @@ export const database = {
322376
},
323377

324378
getBillBySpotId(spotId) {
325-
const summaryRows = db
326-
.prepare(
327-
`SELECT user_id, SUM(total_amount) AS total
328-
FROM orders
329-
WHERE spot_id = ?
330-
GROUP BY user_id`
331-
)
332-
.all(spotId);
333-
334-
const total = summaryRows.reduce((sum, row) => sum + row.total, 0);
379+
const summaryRows = state.orders.filter((order) => order.spot_id === spotId);
380+
381+
const total = summaryRows.reduce((sum, row) => sum + row.total_amount, 0);
335382
const userTotals = summaryRows.reduce((acc, row) => {
336-
acc[row.user_id] = row.total;
383+
acc[row.user_id] = (acc[row.user_id] || 0) + row.total_amount;
337384
return acc;
338385
}, {});
339386

340-
const orderCount = db.prepare('SELECT COUNT(*) AS count FROM orders WHERE spot_id = ?').get(spotId).count;
341-
342387
return {
343388
spotId,
344389
total,
345390
userTotals,
346-
orderCount,
391+
orderCount: summaryRows.length,
347392
};
348393
},
349394
};

0 commit comments

Comments
 (0)