From ac499d4152d08a5a5b523297036cea25a75a7424 Mon Sep 17 00:00:00 2001 From: Archit Varma Date: Mon, 22 Jun 2026 18:24:56 +0530 Subject: [PATCH 1/3] chore: update frontend submodule (login transition fix, offline font) --- frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend b/frontend index 5c0f5e4..fa4b3dc 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 5c0f5e47087bcd438008005f3190e9b83aa61414 +Subproject commit fa4b3dc48497f669d2ce804abac70fa268c85a39 From 7075759f85bc839a437f874ca8015fbc44fdf840 Mon Sep 17 00:00:00 2001 From: Archit Varma Date: Wed, 1 Jul 2026 13:25:39 +0530 Subject: [PATCH 2/3] fix(security): global rate limiting and hashed PIN storage --- main/db.ts | 155 +++++++++----- main/ipc.ts | 37 ++-- main/kds-server.ts | 63 ++++-- main/middleware/security.ts | 72 +++++++ main/routes/auth.ts | 105 ++++++++-- main/routes/order-items.ts | 19 +- main/routes/settings.ts | 70 ++++--- main/routes/staff.ts | 25 ++- main/server.ts | 19 +- main/services/kds.ts | 5 +- test-data/shred farmer cafe-Sheet1.csv | 253 +++++++++++++++++++++++ test-data/shred farmer cafe-gym diet.csv | 45 ++++ test-data/stress-addons.csv | 51 +++++ test-data/stress-categories.csv | 16 ++ test-data/stress-products.csv | 50 +++++ test-data/test1-veg-tags.csv | 6 + test-data/test2-messy-data.csv | 4 + test-data/test3-missing-fields.csv | 5 + 18 files changed, 859 insertions(+), 141 deletions(-) create mode 100644 main/middleware/security.ts create mode 100644 test-data/shred farmer cafe-Sheet1.csv create mode 100644 test-data/shred farmer cafe-gym diet.csv create mode 100644 test-data/stress-addons.csv create mode 100644 test-data/stress-categories.csv create mode 100644 test-data/stress-products.csv create mode 100644 test-data/test1-veg-tags.csv create mode 100644 test-data/test2-messy-data.csv create mode 100644 test-data/test3-missing-fields.csv diff --git a/main/db.ts b/main/db.ts index 585f689..11bf6a2 100644 --- a/main/db.ts +++ b/main/db.ts @@ -99,7 +99,7 @@ function autoRepairPaymentDetails(): void { const toFix: { id: number; value: string }[] = []; for (const row of rows) { - try { JSON.parse(row.payment_details); continue; } catch {} + try { JSON.parse(row.payment_details); continue; } catch { } const wrapped = '[' + String(row.payment_details).replace(/\}\s*,\s*\{/g, '},{') + ']'; let parsed: any[]; @@ -116,7 +116,7 @@ function autoRepairPaymentDetails(): void { const dedupedSum = deduped.reduce((s, p) => s + (Number(p.amount) || 0), 0); const rawSum = parsed.reduce((s, p) => s + (Number(p.amount) || 0), 0); const chosen = Math.abs(dedupedSum - row.paid_amount) <= 0.02 ? deduped - : Math.abs(rawSum - row.paid_amount) <= 0.02 ? parsed : null; + : Math.abs(rawSum - row.paid_amount) <= 0.02 ? parsed : null; if (!chosen) continue; toFix.push({ id: row.id, value: JSON.stringify(chosen) }); @@ -230,9 +230,9 @@ export interface RestoreResult { export function restoreBackup(backupPath: string, forceDirect: boolean = false): RestoreResult { console.log('[DB] restoreBackup: Starting restore from:', backupPath); - + const backupDb = new Database(backupPath, { readonly: true }); - + const metaRow = backupDb.prepare(`SELECT value FROM _flo_meta WHERE key = 'schema_version'`).get() as { value: string } | undefined; const backupSchemaVersion = metaRow ? parseInt(metaRow.value, 10) : 0; backupDb.close(); @@ -241,14 +241,14 @@ export function restoreBackup(backupPath: string, forceDirect: boolean = false): const currentVersion = getCurrentSchemaVersion(); console.log(`[DB] Backup schema version: ${backupSchemaVersion}, Current: ${currentVersion}`); - + if (forceDirect || backupSchemaVersion === currentVersion) { console.log('[DB] restoreBackup: Direct restore (same schema version)'); closeDatabase(); const dbPath = getDbPath(); fs.copyFileSync(backupPath, dbPath); initDatabase(); - + return { success: true, mode: 'direct', @@ -257,45 +257,69 @@ export function restoreBackup(backupPath: string, forceDirect: boolean = false): tablesRestored: getTables(currentDb).length }; } - + console.log('[DB] restoreBackup: Data-only restore (schema version mismatch)'); return dataOnlyRestore(backupPath, backupSchemaVersion, currentVersion); } +/** Return true only if the string is a safe SQL identifier (letters, digits, underscore). */ +function isSafeIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + function dataOnlyRestore(backupPath: string, backupVersion: number, currentVersion: number): RestoreResult { const backupDb = new Database(backupPath, { readonly: true }); const currentDb = getDatabase(); - + const backupTables = getTables(backupDb); const currentTables = getTables(currentDb); - + const commonTables = backupTables.filter(t => currentTables.includes(t)); let tablesRestored = 0; - + + // Escape single-quotes in the path so the ATTACH string literal is safe + // (e.g. macOS paths containing apostrophes like /Users/O'Brien/backup.db) + const safeBackupPath = backupPath.replace(/'/g, "''"); + currentDb.exec('BEGIN IMMEDIATE'); - + try { + // ATTACH once outside the loop — avoids repeated injection attempts and is faster + currentDb.exec(`ATTACH DATABASE '${safeBackupPath}' AS _restore_src`); + for (const tableName of commonTables) { + // ── Guard: skip tables whose name isn't a plain SQL identifier ────────── + if (!isSafeIdentifier(tableName)) { + console.warn(`[DB] dataOnlyRestore: skipping table with unsafe name: ${JSON.stringify(tableName)}`); + continue; + } + const backupCols = getColumns(backupDb, tableName); const currentCols = getColumns(currentDb, tableName); - const commonCols = backupCols.filter(c => currentCols.includes(c)); - + + // ── Guard: skip columns whose name isn't a plain SQL identifier ───────── + const commonCols = backupCols + .filter(c => currentCols.includes(c)) + .filter(c => { + if (isSafeIdentifier(c)) return true; + console.warn(`[DB] dataOnlyRestore: skipping unsafe column: ${JSON.stringify(c)} in ${tableName}`); + return false; + }); + if (commonCols.length === 0) continue; - - currentDb.exec(`DELETE FROM ${tableName}`); - + const colList = commonCols.join(', '); - currentDb.exec(` - ATTACH DATABASE '${backupPath}' AS backup; - INSERT INTO ${tableName} (${colList}) SELECT ${colList} FROM backup.${tableName} - `); - + + currentDb.exec(`DELETE FROM ${tableName}`); + currentDb.exec(`INSERT INTO ${tableName} (${colList}) SELECT ${colList} FROM _restore_src.${tableName}`); + tablesRestored++; console.log(`[DB] Restored ${tableName}: ${commonCols.length} columns`); } - + + currentDb.exec('DETACH DATABASE _restore_src'); currentDb.exec('COMMIT'); - + return { success: true, mode: 'data_only', @@ -319,6 +343,7 @@ function dataOnlyRestore(backupPath: string, backupVersion: number, currentVersi } } + export function getSchemaVersionFromBackup(backupPath: string): number { try { const backupDb = new Database(backupPath, { readonly: true }); @@ -347,8 +372,25 @@ const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ seedData(); }, }, - // Example future migration: - // { version: 2, name: 'add_loyalty_tiers', up: () => { db.exec(`ALTER TABLE ...`) } }, + { + version: 2, + name: 'hash_plaintext_pins', + up: () => { + // Migrate from plaintext PINs to hashed PINs. + // New installs going forward store only pin_hash. + db.exec(`ALTER TABLE users ADD COLUMN pin_hash TEXT`); + + const usersWithPin = db.prepare('SELECT id, pin FROM users WHERE pin IS NOT NULL').all() as { id: string; pin: string }[]; + for (const user of usersWithPin) { + const pin = String(user.pin || ''); + if (!pin) continue; + // Already a bcrypt hash? + if (pin.startsWith('$2')) continue; + db.prepare('UPDATE users SET pin_hash = ?, pin = NULL WHERE id = ?') + .run(bcrypt.hashSync(pin, 10), user.id); + } + }, + }, ]; function runMigrations(): void { @@ -504,6 +546,7 @@ function createSchema(): void { role TEXT NOT NULL DEFAULT 'cashier' CHECK (role IN ('owner', 'manager', 'cashier', 'waiter', 'chef')), pin TEXT, + pin_hash TEXT, category_ids TEXT, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP, @@ -652,22 +695,22 @@ function seedData(): void { const insert = (key: string, value: string) => db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)').run(key, value); - insert('business_name', 'Shop'); - insert('country', 'IN'); - insert('currency', 'INR'); - insert('currency_symbol', '₹'); - insert('timezone', 'Asia/Kolkata'); - insert('address', ''); - insert('phone', ''); - insert('email', ''); - insert('tax_registered', 'false'); - insert('gstin', ''); - insert('state_code', ''); - insert('tax_scheme', 'regular'); - insert('billing_type', 'postpaid'); + insert('business_name', 'Shop'); + insert('country', 'IN'); + insert('currency', 'INR'); + insert('currency_symbol', '₹'); + insert('timezone', 'Asia/Kolkata'); + insert('address', ''); + insert('phone', ''); + insert('email', ''); + insert('tax_registered', 'false'); + insert('gstin', ''); + insert('state_code', ''); + insert('tax_scheme', 'regular'); + insert('billing_type', 'postpaid'); insert('loyalty_expiry_days', '365'); - insert('cloud_server_url', ''); - insert('cloud_connected', 'false'); + insert('cloud_server_url', ''); + insert('cloud_connected', 'false'); // Default owner account const hashedPassword = bcrypt.hashSync('admin123', 10); @@ -696,9 +739,9 @@ function seedData(): void { // Sample categories const cats = [ - ['cat-1', 'Food', '#FF6B6B', '🍔', 1], + ['cat-1', 'Food', '#FF6B6B', '🍔', 1], ['cat-2', 'Beverages', '#4ECDC4', '🥤', 2], - ['cat-3', 'Desserts', '#FFE66D', '🍰', 3], + ['cat-3', 'Desserts', '#FFE66D', '🍰', 3], ]; for (const [id, name, color, icon, sort] of cats) { db.prepare(` @@ -709,16 +752,16 @@ function seedData(): void { // Sample products const products = [ - ['prod-1', 'cat-1', 'Cheeseburger', 250.00, 1], - ['prod-2', 'cat-1', 'Veggie Wrap', 180.00, 2], - ['prod-3', 'cat-1', 'Chicken Sandwich', 220.00, 3], - ['prod-4', 'cat-1', 'French Fries', 80.00, 4], - ['prod-5', 'cat-2', 'Cola', 60.00, 1], - ['prod-6', 'cat-2', 'Fresh Lime Soda', 70.00, 2], - ['prod-7', 'cat-2', 'Mango Lassi', 90.00, 3], - ['prod-8', 'cat-2', 'Mineral Water', 30.00, 4], + ['prod-1', 'cat-1', 'Cheeseburger', 250.00, 1], + ['prod-2', 'cat-1', 'Veggie Wrap', 180.00, 2], + ['prod-3', 'cat-1', 'Chicken Sandwich', 220.00, 3], + ['prod-4', 'cat-1', 'French Fries', 80.00, 4], + ['prod-5', 'cat-2', 'Cola', 60.00, 1], + ['prod-6', 'cat-2', 'Fresh Lime Soda', 70.00, 2], + ['prod-7', 'cat-2', 'Mango Lassi', 90.00, 3], + ['prod-8', 'cat-2', 'Mineral Water', 30.00, 4], ['prod-9', 'cat-3', 'Chocolate Brownie', 120.00, 1], - ['prod-10','cat-3', 'Ice Cream Scoop', 80.00, 2], + ['prod-10', 'cat-3', 'Ice Cream Scoop', 80.00, 2], ]; for (const [id, catId, name, price, sort] of products) { db.prepare(` @@ -729,8 +772,8 @@ function seedData(): void { // Sample tables const tables = [ - ['tbl-1', 'T1', 4], ['tbl-2', 'T2', 4], ['tbl-3', 'T3', 6], - ['tbl-4', 'T4', 2], ['tbl-5', 'T5', 4], ['tbl-6', 'T6', 8], + ['tbl-1', 'T1', 4], ['tbl-2', 'T2', 4], ['tbl-3', 'T3', 6], + ['tbl-4', 'T4', 2], ['tbl-5', 'T5', 4], ['tbl-6', 'T6', 8], ]; for (const [id, number, capacity] of tables) { db.prepare(` @@ -773,6 +816,12 @@ export function now(): string { return new Date().toISOString(); } +/** Verify a user PIN against the stored pin_hash. */ +export function verifyPin(storedHash: string | null | undefined, inputPin: string | number): boolean { + if (!storedHash || !inputPin) return false; + return bcrypt.compareSync(String(inputPin), storedHash); +} + /** Parse JSON string fields on order_item rows returned from SQLite. * Stored as JSON.stringify(value) — may be "null", "[...]", "{...}" etc. * Returns actual JS value (array / object / null) so the frontend can map/iterate. */ diff --git a/main/ipc.ts b/main/ipc.ts index 3b4cbba..57383d8 100644 --- a/main/ipc.ts +++ b/main/ipc.ts @@ -8,7 +8,7 @@ export function registerIpcHandlers(): void { ipcMain.handle('backup-database', async () => { try { console.log('[IPC] backup-database: Starting...'); - + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const result = await dialog.showSaveDialog({ defaultPath: path.join(app.getPath('documents'), `flo-backup-${timestamp}.db`), @@ -20,10 +20,10 @@ export function registerIpcHandlers(): void { } const { path: backupPath, schemaVersion } = await createBackup(result.filePath); - + console.log('[IPC] backup-database: Complete:', backupPath); - return { - success: true, + return { + success: true, path: backupPath, schemaVersion, message: `Backup saved (Schema v${schemaVersion})` @@ -47,16 +47,16 @@ export function registerIpcHandlers(): void { const backupPath = result.filePaths[0]; const backupVersion = getSchemaVersionFromBackup(backupPath); - + if (backupVersion === 0) { - return { - success: false, + return { + success: false, error: 'Invalid backup file: missing schema version metadata. This backup may have been created with an older version of FloDesktop.' }; } - + const versionMismatch = backupVersion !== getCurrentSchemaVersion(); - + if (versionMismatch) { const confirmResult = await dialog.showMessageBox({ type: 'warning', @@ -66,11 +66,11 @@ export function registerIpcHandlers(): void { message: `Backup was created with Schema v${backupVersion}`, detail: `Current database uses Schema v${getCurrentSchemaVersion()}.\n\nRestoring will import data only (common fields) to preserve new database structure.\n\nDo you want to continue?` }); - + if (confirmResult.response !== 0) { return { success: false, error: 'Cancelled' }; } - + const restoreResult = restoreBackup(backupPath, false); return { success: restoreResult.success, @@ -78,13 +78,13 @@ export function registerIpcHandlers(): void { backupVersion, currentVersion: getCurrentSchemaVersion(), tablesRestored: restoreResult.tablesRestored, - message: restoreResult.success + message: restoreResult.success ? `Restored ${restoreResult.tablesRestored} tables (data-only mode due to version mismatch)` : `Restore failed: ${restoreResult.error}`, error: restoreResult.error }; } - + const restoreResult = restoreBackup(backupPath, true); return { success: restoreResult.success, @@ -249,12 +249,13 @@ export function registerIpcHandlers(): void { try { const bcrypt = require('bcryptjs'); const hashedPassword = bcrypt.hashSync(userData.password, 10); + const hashedPin = userData.pin ? bcrypt.hashSync(userData.pin.toString(), 10) : null; const db = getDatabase(); const result = db.prepare(` - INSERT INTO users (name, email, password, pin, role, is_active, created_at, updated_at) + INSERT INTO users (name, email, password, pin_hash, role, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?, ?) - `).run(userData.name, userData.email, hashedPassword, userData.pin || null, + `).run(userData.name, userData.email, hashedPassword, hashedPin, userData.role || 'cashier', now(), now()); return { success: true, id: result.lastInsertRowid }; @@ -271,7 +272,11 @@ export function registerIpcHandlers(): void { if (userData.name) { updates.push('name = ?'); params.push(userData.name); } if (userData.email) { updates.push('email = ?'); params.push(userData.email); } - if (userData.pin !== undefined) { updates.push('pin = ?'); params.push(userData.pin); } + if (userData.pin !== undefined) { + updates.push('pin_hash = ?'); + const bcrypt = require('bcryptjs'); + params.push(userData.pin ? bcrypt.hashSync(userData.pin.toString(), 10) : null); + } if (userData.role) { updates.push('role = ?'); params.push(userData.role); } if (userData.is_active !== undefined) { updates.push('is_active = ?'); params.push(userData.is_active ? 1 : 0); } diff --git a/main/kds-server.ts b/main/kds-server.ts index b5601d5..bd4e2e7 100644 --- a/main/kds-server.ts +++ b/main/kds-server.ts @@ -1,5 +1,6 @@ import express, { Express, Request, Response, NextFunction } from 'express'; import cors from 'cors'; +import jwt from 'jsonwebtoken'; import { WebSocketServer, WebSocket } from 'ws'; import * as http from 'http'; import * as os from 'os'; @@ -7,6 +8,8 @@ import * as path from 'path'; import * as fs from 'fs'; import { getDatabase, parseItemJson } from './db'; import { setupKdsWebSocket } from './services/kds'; +import { getJWTSecret } from './routes/auth'; +import { rateLimit } from './middleware/security'; let kdsServer: http.Server | null = null; @@ -58,11 +61,40 @@ export function startKdsServer(): Promise { return new Promise((resolve, reject) => { const app: Express = express(); - app.use(cors()); + app.use(cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + if (/^https?:\/\/localhost(:[0-9]+)?$/.test(origin) || + /^https?:\/\/(127\.0\.0\.1|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/.test(origin)) { + return callback(null, true); + } + callback(new Error('Not allowed by CORS')); + } + })); app.use(express.json()); + // ── Global API rate limiting ────────────────────────────────────────── + app.use('/api', rateLimit({ windowMs: 60 * 1000, max: 100 })); + + // ── KDS Auth Middleware ─────────────────────────────────────────────── + const requireAuth = (req: Request, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + const token = authHeader.split(' ')[1]; + try { + const decoded = jwt.verify(token, getJWTSecret()); + (req as any).user = decoded; + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } + }; + // ── KDS API endpoints (same database, minimal routes) ───────────── - + // Health check app.get('/api/health', (_req: Request, res: Response) => { res.json({ @@ -83,7 +115,7 @@ export function startKdsServer(): Promise { const db = getDatabase(); const bcrypt = require('bcryptjs'); - + const user = db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email) as any; if (!user || !bcrypt.compareSync(password, user.password)) { return res.status(401).json({ error: 'Invalid credentials' }); @@ -94,7 +126,14 @@ export function startKdsServer(): Promise { return res.status(403).json({ error: 'Access denied. Only kitchen staff allowed.' }); } + const token = jwt.sign( + { userId: user.id, email: user.email, role: user.role }, + getJWTSecret(), + { expiresIn: '24h' } + ); + res.json({ + access_token: token, user: { id: user.id, name: user.name, @@ -109,11 +148,11 @@ export function startKdsServer(): Promise { }); // Get orders for KDS (pending, preparing, ready) - app.get('/api/kds/orders', (req: Request, res: Response) => { + app.get('/api/kds/orders', requireAuth, (req: Request, res: Response) => { try { const db = getDatabase(); const categoryIds = req.query.category_ids ? String(req.query.category_ids).split(',') : null; - + let query = ` SELECT DISTINCT o.*, t.number as table_number FROM orders o @@ -123,21 +162,21 @@ export function startKdsServer(): Promise { AND o.created_at >= datetime('now', '-24 hours') ORDER BY o.created_at ASC `; - + const orders = db.prepare(query).all(); - + const ordersWithItems = orders.map((order: any) => { let items = db.prepare('SELECT * FROM order_items WHERE order_id = ?').all(order.id).map(parseItemJson); - + // Filter by category if provided if (categoryIds && categoryIds.length > 0 && categoryIds[0] !== '') { const productIds = db.prepare(` SELECT id FROM products WHERE category_id IN (${categoryIds.map(() => '?').join(',')}) `).all(...categoryIds).map((p: any) => p.id); - + items = items.filter((item: any) => productIds.includes(item.product_id)); } - + return { ...order, table: order.table_number ? { name: order.table_number } : null, @@ -152,7 +191,7 @@ export function startKdsServer(): Promise { }); // Update order item status - app.patch('/api/kds/items/:id/status', (req: Request, res: Response) => { + app.patch('/api/kds/items/:id/status', requireAuth, (req: Request, res: Response) => { try { const { status } = req.body; const validStatuses = ['pending', 'preparing', 'ready', 'served']; @@ -178,7 +217,7 @@ export function startKdsServer(): Promise { }); // Get categories for filtering - app.get('/api/categories', (_req: Request, res: Response) => { + app.get('/api/categories', requireAuth, (_req: Request, res: Response) => { try { const db = getDatabase(); const categories = db.prepare('SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order').all(); diff --git a/main/middleware/security.ts b/main/middleware/security.ts new file mode 100644 index 0000000..0f6d122 --- /dev/null +++ b/main/middleware/security.ts @@ -0,0 +1,72 @@ +import { Request, Response, NextFunction } from 'express'; + +interface RateLimitRecord { + count: number; + resetAt: number; +} + +interface RateLimitOptions { + windowMs?: number; + max?: number; + message?: string; + skipSuccessfulRequests?: boolean; +} + +const DEFAULT_WINDOW_MS = 60 * 1000; // 1 minute +const DEFAULT_MAX = 100; + +/** + * Simple in-memory rate limiter for the local Express API. + * Uses IP address as the key. Designed for a single-tenant desktop app. + */ +export function rateLimit(options: RateLimitOptions = {}) { + const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS; + const max = options.max ?? DEFAULT_MAX; + const message = options.message ?? 'Too many requests, please try again later.'; + + const requests = new Map(); + + return (req: Request, res: Response, next: NextFunction) => { + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + const now = Date.now(); + + let record = requests.get(ip); + if (!record || record.resetAt <= now) { + record = { count: 0, resetAt: now + windowMs }; + requests.set(ip, record); + } + + record.count += 1; + + res.setHeader('RateLimit-Limit', String(max)); + res.setHeader('RateLimit-Remaining', String(Math.max(0, max - record.count))); + res.setHeader('RateLimit-Reset', new Date(record.resetAt).toISOString()); + + if (record.count > max) { + return res.status(429).json({ error: message }); + } + + if (options.skipSuccessfulRequests) { + const originalSend = res.send.bind(res); + res.send = (body: any) => { + if (res.statusCode >= 200 && res.statusCode < 300) { + record!.count = Math.max(0, record!.count - 1); + } + return originalSend(body); + }; + } + + next(); + }; +} + +/** + * Stricter rate limiter for authentication endpoints. + */ +export function authRateLimit() { + return rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10, + message: 'Too many authentication attempts. Please try again later.', + }); +} diff --git a/main/routes/auth.ts b/main/routes/auth.ts index 6ff9c7c..121dba8 100644 --- a/main/routes/auth.ts +++ b/main/routes/auth.ts @@ -2,10 +2,24 @@ import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; +import crypto from 'crypto'; import { getDatabase, now } from '../db'; const router = Router(); -const JWT_SECRET = process.env.JWT_SECRET || 'flo-local-secret-change-in-production'; +export function getJWTSecret(): string { + const db = getDatabase(); + const secretRow = db.prepare("SELECT value FROM settings WHERE key = 'jwt_secret'").get() as { value: string } | undefined; + if (secretRow?.value) return secretRow.value; + + if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'flo-local-secret-change-in-production') { + return process.env.JWT_SECRET; + } + + const newSecret = crypto.randomBytes(32).toString('hex'); + db.prepare("INSERT INTO settings (key, value, updated_at) VALUES ('jwt_secret', ?, ?)").run(newSecret, now()); + return newSecret; +} + const JWT_EXPIRES_IN = '24h'; /** @@ -19,24 +33,67 @@ function buildLocalTenant(db: ReturnType, userRole: string) return { id: 1, - business_name: s.business_name || 'Shop', - slug: 'local', - database_name: 'local', - business_type: s.business_type || 'restaurant', - country: s.country || 'IN', - currency: s.currency || 'INR', + business_name: s.business_name || 'Shop', + slug: 'local', + database_name: 'local', + business_type: s.business_type || 'restaurant', + country: s.country || 'IN', + currency: s.currency || 'INR', currency_symbol: s.currency_symbol || '₹', - timezone: s.timezone || 'Asia/Kolkata', - plan: 'desktop', - status: 'active', - role: userRole, // user's role — AuthGuard uses this for routing + timezone: s.timezone || 'Asia/Kolkata', + plan: 'desktop', + status: 'active', + role: userRole, // user's role — AuthGuard uses this for routing }; } +// ── Rate Limiting (In-Memory for local offline apps) ────────────────────────── +const loginAttempts = new Map(); +const MAX_ATTEMPTS = 5; +const LOCKOUT_MINUTES = 15; + +function checkRateLimit(ip: string): { allowed: boolean; waitMinutes?: number } { + const nowMs = Date.now(); + let record = loginAttempts.get(ip); + + if (record) { + if (record.lockedUntil > nowMs) { + const waitMinutes = Math.ceil((record.lockedUntil - nowMs) / 60000); + return { allowed: false, waitMinutes }; + } + // If lock expired, reset + if (record.lockedUntil > 0 && record.lockedUntil <= nowMs) { + record = { count: 0, lockedUntil: 0 }; + loginAttempts.set(ip, record); + } + } + return { allowed: true }; +} + +function incrementFailedLogin(ip: string) { + const record = loginAttempts.get(ip) || { count: 0, lockedUntil: 0 }; + record.count += 1; + if (record.count >= MAX_ATTEMPTS) { + record.lockedUntil = Date.now() + LOCKOUT_MINUTES * 60000; + } + loginAttempts.set(ip, record); +} + +function resetSuccessfulLogin(ip: string) { + loginAttempts.delete(ip); +} +// ───────────────────────────────────────────────────────────────────────────── + // ── POST /api/auth/login ────────────────────────────────────────────────────── router.post('/login', (req: Request, res: Response) => { try { + const ip = req.ip || req.socket.remoteAddress || 'unknown'; + const rateLimit = checkRateLimit(ip); + if (!rateLimit.allowed) { + return res.status(429).json({ error: `Too many failed attempts. Try again in ${rateLimit.waitMinutes} minutes.` }); + } + const { email, password } = req.body; if (!email || !password) { @@ -47,12 +104,15 @@ router.post('/login', (req: Request, res: Response) => { const user = db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email) as any; if (!user || !bcrypt.compareSync(password, user.password)) { + incrementFailedLogin(ip); return res.status(401).json({ error: 'Invalid credentials' }); } + resetSuccessfulLogin(ip); + const token = jwt.sign( { userId: user.id, email: user.email, role: user.role }, - JWT_SECRET, + getJWTSecret(), { expiresIn: JWT_EXPIRES_IN } ); @@ -89,7 +149,7 @@ router.post('/tenants/select', (req: Request, res: Response) => { } const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, getJWTSecret()) as any; const db = getDatabase(); const user = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(decoded.userId) as any; @@ -100,7 +160,7 @@ router.post('/tenants/select', (req: Request, res: Response) => { // Re-issue token with tenant context embedded (same payload — desktop is single-tenant) const newToken = jwt.sign( { userId: user.id, email: user.email, role: user.role, tenantId: 1 }, - JWT_SECRET, + getJWTSecret(), { expiresIn: JWT_EXPIRES_IN } ); @@ -130,10 +190,10 @@ router.post('/refresh', (req: Request, res: Response) => { } const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, getJWTSecret()) as any; const newToken = jwt.sign( { userId: decoded.userId, email: decoded.email, role: decoded.role, tenantId: decoded.tenantId }, - JWT_SECRET, + getJWTSecret(), { expiresIn: JWT_EXPIRES_IN } ); @@ -157,7 +217,7 @@ router.get('/me', (req: Request, res: Response) => { } const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, getJWTSecret()) as any; const db = getDatabase(); const user = db.prepare('SELECT id, name, email, role FROM users WHERE id = ?').get(decoded.userId) as any; @@ -186,7 +246,7 @@ router.post('/password/change', (req: Request, res: Response) => { } const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, getJWTSecret()) as any; const db = getDatabase(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.userId) as any; @@ -263,7 +323,7 @@ router.post('/setup/initialize', (req: Request, res: Response) => { const token = jwt.sign( { userId, email, role: 'owner' }, - JWT_SECRET, + getJWTSecret(), { expiresIn: JWT_EXPIRES_IN } ); @@ -308,6 +368,13 @@ router.post('/setup/seed', (req: Request, res: Response) => { const db = getDatabase(); + // ── Security: only allowed during first-run (no users exist yet) ────────── + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }; + if (userCount.count > 0) { + return res.status(403).json({ error: 'Setup already complete. This endpoint is disabled.' }); + } + // ───────────────────────────────────────────────────────────────────────── + if (business_name) { db.prepare(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_name', ?, ?)`).run(business_name, now()); } diff --git a/main/routes/order-items.ts b/main/routes/order-items.ts index 3b9d636..f835124 100644 --- a/main/routes/order-items.ts +++ b/main/routes/order-items.ts @@ -1,9 +1,24 @@ import { Router, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; import { getDatabase, now, parseItemJson, withTxn } from '../db'; import { notifyKdsUpdate } from '../services/kds'; const router = Router(); +const JWT_SECRET = process.env.JWT_SECRET || 'flo-local-secret-change-in-production'; + +/** Decode the Bearer token and return the role, or null if missing/invalid. */ +function getRoleFromToken(req: Request): string | null { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) return null; + try { + const decoded = jwt.verify(authHeader.split(' ')[1], JWT_SECRET) as { role?: string }; + return decoded.role ?? null; + } catch { + return null; + } +} + // PATCH /api/order-items/:id/status — update a single item's kitchen status router.patch('/:id/status', (req: Request, res: Response) => { try { @@ -43,7 +58,7 @@ router.patch('/:id/status', (req: Request, res: Response) => { router.patch('/:orderId/items/:itemId/cancel', (req: Request, res: Response) => { try { const { orderId, itemId } = req.params; - const userRole = req.headers['x-user-role'] as string; + const userRole = getRoleFromToken(req); if (!userRole || !['owner', 'manager'].includes(userRole.toLowerCase())) { return res.status(403).json({ error: 'Only owner or manager can cancel items' }); @@ -96,7 +111,7 @@ router.patch('/:orderId/items/:itemId/cancel', (req: Request, res: Response) => router.patch('/:orderId/items/:itemId/restore', (req: Request, res: Response) => { try { const { orderId, itemId } = req.params; - const userRole = req.headers['x-user-role'] as string; + const userRole = getRoleFromToken(req); if (!userRole || !['owner', 'manager'].includes(userRole.toLowerCase())) { return res.status(403).json({ error: 'Only owner or manager can restore items' }); diff --git a/main/routes/settings.ts b/main/routes/settings.ts index fb9d50d..c07a468 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -24,31 +24,31 @@ function upsertSettings(db: ReturnType, entries: Record) { return { - business_name: s.business_name || '', - timezone: s.timezone || 'Asia/Kolkata', - currency: s.currency || 'INR', - country: s.country || 'IN', - gstin: s.gstin || '', - state_code: s.state_code || '', + business_name: s.business_name || '', + timezone: s.timezone || 'Asia/Kolkata', + currency: s.currency || 'INR', + country: s.country || 'IN', + gstin: s.gstin || '', + state_code: s.state_code || '', business_address: s.business_address || '', - business_phone: s.business_phone || '', - billing_type: s.billing_type || 'postpaid', - bill_show_name: s.bill_show_name !== 'false', + business_phone: s.business_phone || '', + billing_type: s.billing_type || 'postpaid', + bill_show_name: s.bill_show_name !== 'false', bill_show_address: s.bill_show_address !== 'false', - bill_show_phone: s.bill_show_phone !== 'false', - bill_show_gstn: s.bill_show_gstn === 'true', + bill_show_phone: s.bill_show_phone !== 'false', + bill_show_gstn: s.bill_show_gstn === 'true', }; } function taxShape(s: Record) { return { - tax_registered: s.tax_registered === 'true', - gstin: s.gstin || '', - state_code: s.state_code || '', - tax_scheme: s.tax_scheme || 'regular', - country: s.country || 'IN', - loyalty_enabled: s.loyalty_enabled === 'true', - loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), + tax_registered: s.tax_registered === 'true', + gstin: s.gstin || '', + state_code: s.state_code || '', + tax_scheme: s.tax_scheme || 'regular', + country: s.country || 'IN', + loyalty_enabled: s.loyalty_enabled === 'true', + loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), loyalty_points_per_rs: parseFloat(s.loyalty_points_per_rs || '1'), loyalty_redeem_value: parseFloat(s.loyalty_redeem_value || '0.25'), }; @@ -108,10 +108,10 @@ router.get('/loyalty', (req: Request, res: Response) => { try { const s = getAllSettings(getDatabase()); res.json({ - loyalty_enabled: s.loyalty_enabled === 'true', - loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), + loyalty_enabled: s.loyalty_enabled === 'true', + loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), loyalty_points_per_rs: parseFloat(s.loyalty_points_per_rs || '1'), - loyalty_redeem_value: parseFloat(s.loyalty_redeem_value || '0.25'), + loyalty_redeem_value: parseFloat(s.loyalty_redeem_value || '0.25'), }); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -125,10 +125,10 @@ router.put('/loyalty', (req: Request, res: Response) => { upsertSettings(db, { loyalty_enabled, loyalty_expiry_days, loyalty_points_per_rs, loyalty_redeem_value }); const s = getAllSettings(db); res.json({ - loyalty_enabled: s.loyalty_enabled === 'true', - loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), + loyalty_enabled: s.loyalty_enabled === 'true', + loyalty_expiry_days: parseInt(s.loyalty_expiry_days || '365'), loyalty_points_per_rs: parseFloat(s.loyalty_points_per_rs || '1'), - loyalty_redeem_value: parseFloat(s.loyalty_redeem_value || '0.25'), + loyalty_redeem_value: parseFloat(s.loyalty_redeem_value || '0.25'), }); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -137,9 +137,23 @@ router.put('/loyalty', (req: Request, res: Response) => { // ── Generic key-value routes ─────────────────────────────────────────────── +const SENSITIVE_KEYS = ['cloud_api_key', 'jwt_secret', 'pin', 'password', 'token', 'secret', 'key']; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.some(sensitive => key.toLowerCase().includes(sensitive)); +} + router.get('/', (req: Request, res: Response) => { try { const s = getAllSettings(getDatabase()); + + // Remove sensitive keys from the dump + for (const key of Object.keys(s)) { + if (isSensitiveKey(key)) { + delete s[key]; + } + } + res.json({ settings: s }); } catch (error: any) { res.status(500).json({ error: error.message }); @@ -148,8 +162,14 @@ router.get('/', (req: Request, res: Response) => { router.get('/:key', (req: Request, res: Response) => { try { + const key = req.params.key; + + if (isSensitiveKey(key)) { + return res.status(403).json({ error: 'Access denied to sensitive setting' }); + } + const db = getDatabase(); - const setting = db.prepare('SELECT * FROM settings WHERE key = ?').get(req.params.key); + const setting = db.prepare('SELECT * FROM settings WHERE key = ?').get(key); if (!setting) { return res.status(404).json({ error: 'Setting not found' }); } diff --git a/main/routes/staff.ts b/main/routes/staff.ts index 69c1cdd..e5c80b0 100644 --- a/main/routes/staff.ts +++ b/main/routes/staff.ts @@ -16,7 +16,7 @@ const router = Router(); router.get('/', (req: Request, res: Response) => { try { const db = getDatabase(); - let query = 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE 1=1'; + let query = 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE 1=1'; const params: any[] = []; if (req.query.role) { @@ -45,7 +45,7 @@ router.get('/:id', (req: Request, res: Response) => { try { const db = getDatabase(); const member = db.prepare( - 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id) as any; if (!member) { @@ -91,13 +91,15 @@ router.post('/', (req: Request, res: Response) => { const id = uuidv4(); const hashedPassword = bcrypt.hashSync(password, 10); + const hashedPin = pin ? bcrypt.hashSync(String(pin), 10) : null; + db.prepare(` - INSERT INTO users (id, name, email, password, role, pin, is_active, created_at, updated_at) + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) - `).run(id, name, email || null, hashedPassword, role, pin || null, now(), now()); + `).run(id, name, email || null, hashedPassword, role, hashedPin, now(), now()); const member = db.prepare( - 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE id = ?' ).get(id); res.status(201).json({ staff: member }); @@ -133,6 +135,9 @@ router.put('/:id', (req: Request, res: Response) => { } const hashedPassword = password ? bcrypt.hashSync(password, 10) : member.password; + const hashedPin = pin !== undefined + ? (pin ? bcrypt.hashSync(String(pin), 10) : null) + : member.pin_hash; db.prepare(` UPDATE users SET @@ -140,19 +145,19 @@ router.put('/:id', (req: Request, res: Response) => { email = COALESCE(?, email), password = ?, role = COALESCE(?, role), - pin = COALESCE(?, pin), + pin_hash = ?, is_active = COALESCE(?, is_active), updated_at = ? WHERE id = ? `).run( name || null, email || null, hashedPassword, - role || null, pin || null, + role || null, hashedPin, is_active !== undefined ? (is_active ? 1 : 0) : null, now(), req.params.id ); const updated = db.prepare( - 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); res.json({ staff: updated }); @@ -197,7 +202,7 @@ router.post('/:id/deactivate', (req: Request, res: Response) => { db.prepare('UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?').run(now(), req.params.id); const updated = db.prepare( - 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); res.json({ staff: updated }); } catch (error: any) { @@ -214,7 +219,7 @@ router.post('/:id/reactivate', (req: Request, res: Response) => { db.prepare('UPDATE users SET is_active = 1, updated_at = ? WHERE id = ?').run(now(), req.params.id); const updated = db.prepare( - 'SELECT id, name, email, role, pin, is_active, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, name, email, role, pin_hash, is_active, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); res.json({ staff: updated }); } catch (error: any) { diff --git a/main/server.ts b/main/server.ts index ccf7bc7..94471a6 100644 --- a/main/server.ts +++ b/main/server.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import { registerRoutes } from './routes'; import { getDbHealth } from './db'; import { setupKdsWebSocket } from './services/kds'; +import { rateLimit } from './middleware/security'; let server: http.Server | null = null; let app: Express; @@ -66,9 +67,25 @@ export function startServer(): Promise { return new Promise((resolve, reject) => { app = express(); - app.use(cors()); + app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (like curl or desktop apps) + if (!origin) return callback(null, true); + + // Allow localhost and local private IPs + if (/^https?:\/\/localhost(:[0-9]+)?$/.test(origin) || + /^https?:\/\/(127\.0\.0\.1|192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/.test(origin)) { + return callback(null, true); + } + + callback(new Error('Not allowed by CORS')); + } + })); app.use(express.json()); + // ── Global API rate limiting ─────────────────────────────────────── + app.use('/api', rateLimit({ windowMs: 60 * 1000, max: 100 })); + // ── API health check ─────────────────────────────────────────────── app.get('/api/health', (_req: Request, res: Response) => { const db = getDbHealth(); diff --git a/main/services/kds.ts b/main/services/kds.ts index c847823..a1e87fa 100644 --- a/main/services/kds.ts +++ b/main/services/kds.ts @@ -1,8 +1,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import { getDatabase, now } from '../db'; import * as jwt from 'jsonwebtoken'; - -const JWT_SECRET = process.env.JWT_SECRET || 'flo-local-secret-change-in-production'; +import { getJWTSecret } from '../routes/auth'; interface KdsClient { ws: WebSocket; @@ -141,7 +140,7 @@ function handleAuth(ws: WebSocket, client: KdsClient, message: any): void { // Validate JWT token if (token) { try { - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, getJWTSecret()) as any; const db = getDatabase(); const user = db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(decoded.userId) as any; diff --git a/test-data/shred farmer cafe-Sheet1.csv b/test-data/shred farmer cafe-Sheet1.csv new file mode 100644 index 0000000..e82eef7 --- /dev/null +++ b/test-data/shred farmer cafe-Sheet1.csv @@ -0,0 +1,253 @@ +Veg Pizza,,, +,,10,12 +Classic Margherita,"Pizza Sauce,Mozzarella Cheese",349,449 +tandoori Margherita,"Tandoori sauce,Mozzarella Cheese",349,449 +Corn Cheese,"Pizza Sauce,Mozzarella Cheese,Sweet Corn",399,499 +Veg Tropical,"Pizza Sauce,Mozzarella Cheese,Onion,Sweet Corn,Capsicum,Pine Apple,Red Paprika",399,499 +Supreme,"Pizza Sauce,Mozzarella Cheese,Onion,Capsicum,Red Paprika,Black Olives,Jalapenos",499,599 +Farm house,"Pizza Sauce,Mozzarella Cheese,Capsicum,Mushroom,Onion,Tomato,Broccoli,Jalapenos",499,599 +Veggie Special,"Chipotle Sauce,Mozzarella Cheese,Tomato,Onion,capsicum,Mushroom,Black Olives,Sweet Corn",549,649 +Tandoori Paneer,"Tandoori sauce,Mozzarella Cheese,Tomato,Onion,Red Paprika,Marinate Paneer",549,649 +indian paneer,"Pizza Sauce,Mozzarella Cheese,Onion,Capsicum,Red Paprika,Marinate Paneer",599,699 +shred farmer special,"Chipotle Sauce,Mozzarella Cheese,Tomato,Onion,Mushroom,Broccoli,Sweet Corn,Jalapenos,Marinate Paneer",599,699 +,,, +,,, +Extra Topping,,79,99 +Medium Cheese Brust,,129, +Large Cheese Brust,,149, +Thin Crust Available Free,,, +Wheat flour Pizza Base Available(Extra Charges),,79,129 +,,, +,Non Veg Pizza,, +,,10,12 +Red Hot Chicken,"Pizza Sauce,Mozzarella Cheese,Onion,Tomato,Spicy Chicken",499,599 +Chicken Tropical,"Pizza Sauce,Mozzarella Cheese,Chicken Kebab,Pine Apple,Corn,Onion,Paprika",499,599 +Roasted Mariana,"Pizza Sauce,Mozzarella Cheese,Olives,Red Paprika,Onion,Jalapeno,Capsicum,Chicken Kabeb",549,649 +Chicken Kabeb,"Pizza Sauce,Mozzarella Cheese,Onion,Red Paprika,Chicken Kabeb,Capsicum",549,649 +Chicken Pepperoni,"Pizza Sauce,Mozzarella Cheese,Chicken Pepperoni",599,699 +Chicken lovers,"Pizza Sauce,Mozzarella Cheese,Chicken Tikka,onion,Keema,Jalapenos",599,699 +Tandoori Chicken,"Tandoori Sauce,Mozzarella Cheese,Onion,Jalapenos,Tomato,Chicken Tikka",649,749 +shred farmer chicken special,"Chipotle Sauce,Mozzarella Cheese,Capsicum,Chicken Tikka,Chicken Kebab,chicken Keema,Black Olives",699,799 +,,, +Extra Topping,,99,129 +thin Crust Available,,, +medium cheese brust,,129, +large cheese brust,,149, +,,, +,,, +,,, +,Pasta veg,, +,,, +Tomato Basil,249,, +tomato & Cream,279,, +creamy alfredo,349,, +,,, +,Pasta non veg,, +,,, +chicken tomato basil,299,, +chicken pink sauce,349,, +Crispy chicken cream,399,, +,,, +,Sub/Salad,, +,,, +American Veggie,179/229,, +Potato Herb,199/249,, +Ultimate veggies,229/279,, +Spicy Paneer,249/299,, +paneer tikka,279/329,, +,,, +,Sub/Salad,, +,,, +peri peri chicken,229/279,, +Chicken Kabeb,249/279,, +Chicken Pepperoni,279/329,, +Chicken tikka,299/349,, +crispy chicken,329/379,, +,,, +,Pizza Bites veg,, +,,, +onion & Capsicum,149,, +Corn & Jalapeno,179,, +Paneer & onion,199,, +,,, +,Pizza Bites non veg,, +,,, +Chicken Kebab & jalapeno,199,, +peri peri chicken & capsicum,229,, +Chicken Tikka & onion,249,, +,,, +,Fries,, +Classic Fries,109,, +"salted,peri peri,lemon pepper",,, +Pop Corn Fries,129,, +Crispy Fries,149,, +loaded cheese fries,249,, +Chicken Cheese fries,299,, +,,, +,Burger veg,, +,,, +aloo patty,99,, +tangy tandoori,129,, +veggie burger,149,, +tandoori paneer,179,, +spicy paneer,199,, +Veg King,279,, +add on cheese slice,30,, +,,, +,burger non veg,, +,,, +egg burger,149,, +crispy chicken,179,, +grilled chicken,199,, +Zinger Chicken,229,, +spicy chicken,249,, +Smash Chicken,349,, +King Burger,379,, +Mutton Burger,449,, +add on cheese slice,30,, +,,, +,Wrap's veg,, +,whole wheat/wheat flour,, +potato tikki,179/199,, +fully veggie,199/229,, +crispy paneer,229/249,, +Paneer Tikka ,249/279,, +Exotic Veggie,299/329,, +add on cheese slice,30,, +,,, +,Wrap's non veg,, +,whole wheat/wheat flour,, +egg roll,179/199,, +chicken kabeb,199/229,, +Chicken Keema,229/249,, +Chicken Tikka,249/279,, +Egg Chicken Roll,279/299,, +Crispy Chicken,299/329,, +add on cheese slice,30,, +Add on Egg,30,, +Extra Chicken,50,, +,,, +,Sandwich veg,, +,,, +corn cheese,149,, +veggie delight,199,, +cheesey mushroom,199,, +crispy paneer,229,, +Paneer Tikka,249,, +add on cheese slice,30,, +,,, +,Sandwich non veg,, +,,, +chicken kabeb,199,, +Peri peri Keema ,229,, +crispy chicken,249,, +chicken tikka,279,, +Spicy chicken,299,, +add on cheese slice,30,, +,,, +,,, +,garlic bread's,, +,,, +Basil Garlic Bread,229,, +Cheese Garlic bread,249,, +stuffed garlic bread,279,, +,,, +,garlic bread's non veg,, +,,, +Cheesy chicken garlic bread,249,, +Stuffed chicken garlic bread,299,, +,,, +,,, +, Veg Quesdilla with fries,, +,,, +Veggies Special,249,, +Cheesy Mushroom,279,, +Paneer Tikka,299,, +Extra Cheese,49,, +,,, +,Non Veg Quesdilla with fries,, +,,, +Chicken Keema,279,, +Chicken Pepperoni,299,, +Chichen Tikka,329,, +Extra Cheese,49,, +,,, +,hot drinks,, +,,, +single espresso,79,, +double espresso,99,, +americano,149,, +café latte,179,, +cappuccino,199,, +french vanilla coffee,229,, +hot mocha,249,, +hazelnut mocha,279,, +hot chocolate,299,, +White Chocolate Latte,329,, +add on flavour,,, +hazelnut|caramel|chocolate,30,, +,,, +,Mocktails,, +,,, +lemonade,109,, +Mojito,149,, +Blue Lagoon,179,, +spicy guava,199,, +Jamuntini,229,, +Mint Special,249,, +,,, +,cold coffee's,, +,,, +iced Americano,129,, +Iced Latte,149,, +Iced Coffee,179,, +iced Cappuccino,199,, +iced Mocha,229,, +Cold Coffee,249,, +Mocha Frappe,279,, +white Chocolate frappe,299,, +Spanish latte,329,, +Love Bite Latte,349,, +,,, +,,, +,,, +,,, +,non veg snacks,, +,,, +Chicken PopCorn(20pcs),279,, +Egg Omelette (3Egg),149,, +Chicken Omelette,229,, +Chicken Kabeb,249,, +Chicken Wings With Fries,279,, +BBQ Sauce chicken wings with fries,299,, +Grilled Chicken Wings,349,, +Chicken Strips With Fries,379,, +Chicken Bucket (16pcs),449,, +Crispy Chicken Leg(6pcs),499,, +Grilled Chicken Leg(6pcs),529,, +Crispy Fish n Chips,599,, +,,, +,veg snacks,, +,,, +Spring Roll,179,, +cheese cigar roll,199,, +Crispy Paneer Finger,249,, +Veg Strips(12pcs),299,, +,,, +,dessert,, +,,, +double choco cookie,99,, +Vanilla Ice Cream(0ne Scoop),129,, +Vanilla pocket(4pcs),149,, +Classic Affagato,179,, +Brownie With Ice Cream,229,, +,,, +,Shake's,, +,,, +strawberry,199,, +Chocolate,229,, +strawberry chocolate,249,, +oreo,279,, +kitkat,299,, +brownie shake,329,, +Nutella shake,349,, diff --git a/test-data/shred farmer cafe-gym diet.csv b/test-data/shred farmer cafe-gym diet.csv new file mode 100644 index 0000000..c85ad69 --- /dev/null +++ b/test-data/shred farmer cafe-gym diet.csv @@ -0,0 +1,45 @@ +, +,Meal's +, +grilled bread serve with omelette & veggies,249 +Grilled Paneer with exotic veggies,299 +Tandoori chicken steak with veggies,349 +roast chicken serve with exotic veggies,399 +grilled chicken steak with veggies and egg,449 +lemon thyme roast chicken with cold salad,499 +Thyme roast chicken with rice,549 +roast chicken with mushroom alfredo sauce,599 +Smokey Fish with Broccoli,649 +Roasted Fish With Creamy Salad,699 +, +,peanut butter special +, +peanut butter sandwich,129 +peanut butter with banana,149 +peanut butter shake,199 +, +,rice bowl +, +paneer rice bowl,249 +healthly chicken rice bowl,299 +, +, +,Shredfarmer special shake’s +, +Classice whey shake,179 +banana power shake,199 +Chocolate builder yogurt ,229 +coffee protein shake,229 +strawberry protein shake,249 +mango protein shake,279 +vegan protein shake,299 +Add-ons, +"extra protein scoop (25gm),peanut butter,oats,chia seeds,flax seeds,espresso shot,banana",99 +, +, +, +, +, +, +gravy paneer with atta kulcha or rice,499 +butter chicken with atta kulcha or rice,549 diff --git a/test-data/stress-addons.csv b/test-data/stress-addons.csv new file mode 100644 index 0000000..7b86ec6 --- /dev/null +++ b/test-data/stress-addons.csv @@ -0,0 +1,51 @@ +group_name,addon_name,price,group_required,group_min_select,group_max_select +Size,Small (250ml),0,yes,1,1 +Size,Regular (350ml),30,yes,1,1 +Size,Large (500ml),60,yes,1,1 +Size,Extra Large (600ml),90,yes,1,1 +"Milk Type","Full Cream Milk",0,no,0,1 +"Milk Type","Oat Milk",30,no,0,1 +"Milk Type","Almond Milk",40,no,0,1 +"Milk Type","Soy Milk",35,no,0,1 +"Milk Type","Skimmed Milk",0,no,0,1 +Extras,Extra Espresso Shot,30,no,0,3 +Extras,Extra Sugar,0,no,0,3 +Extras,Whipped Cream,20,no,0,2 +Extras,Vanilla Syrup,20,no,0,2 +Extras,Caramel Syrup,20,no,0,2 +Extras,Hazelnut Syrup,20,no,0,2 +"Toppings & Drizzles","Chocolate Drizzle",15,no,0,2 +"Toppings & Drizzles","Caramel Drizzle",15,no,0,2 +"Toppings & Drizzles","Strawberry Sauce",15,no,0,2 +"Toppings & Drizzles",Crushed Oreo,25,no,0,2 +"Toppings & Drizzles","Chopped Nuts",20,no,0,2 +Temperature,Hot,0,yes,1,1 +Temperature,"Cold (Iced)",0,yes,1,1 +"Crust Type",Classic Hand Tossed,0,yes,1,1 +"Crust Type",Thin Crust,0,yes,1,1 +"Crust Type","Cheese Burst",80,yes,1,1 +"Crust Type","Wheat Flour Base",50,yes,1,1 +"Spice Level",Mild,0,no,0,1 +"Spice Level",Medium,0,no,0,1 +"Spice Level",Hot,0,no,0,1 +"Spice Level","Extra Hot",0,no,0,1 +"Cooking Preference","Well Done",0,no,0,1 +"Cooking Preference","Light Grill",0,no,0,1 +"Cooking Preference","Extra Crispy",0,no,0,1 +"Sauce on Side","Mint Chutney",0,no,0,3 +"Sauce on Side","Tamarind Chutney",0,no,0,3 +"Sauce on Side","Chipotle Mayo",10,no,0,3 +"Sauce on Side","Sriracha",10,no,0,3 +"Sauce on Side","Cheese Dip",20,no,0,3 +"Extra Protein","Extra Chicken",50,no,0,2 +"Extra Protein","Extra Paneer",40,no,0,2 +"Extra Protein","Extra Egg",30,no,0,2 +"Add-on Slice","Cheese Slice",30,no,0,3 +"Add-on Slice","Processed Cheese",25,no,0,3 +"Add-on Slice","Smoked Cheese",35,no,0,3 +Size,Small (250ml),0,yes,1,1 +Extras,Extra Espresso Shot,30,no,0,3 +"Milk Type","Full Cream Milk",0,no,0,1 +,Missing group name — should error,20,no,0,1 +"Valid Group",,15,no,0,1 +"Bad Price Group","Item with bad price",not-a-number,no,0,1 diff --git a/test-data/stress-categories.csv b/test-data/stress-categories.csv new file mode 100644 index 0000000..88b96d2 --- /dev/null +++ b/test-data/stress-categories.csv @@ -0,0 +1,16 @@ +name,description,color,icon,sort_order +"Coffee & Hot Drinks",Hot espresso-based and brewed drinks,brown,☕,1 +Cold Beverages,Iced and chilled drinks for warm days,blue,🧊,2 +"Snacks, Bites & Sides","Quick bites, appetizers and side dishes",yellow,🍟,3 +"Burgers, Wraps & Rolls","Hearty burgers, wraps and kathi rolls",red,🍔,4 +Pizzas,Stone-baked pizzas with fresh toppings,orange,🍕,5 +"Desserts & Sweets","Ice creams, cakes, waffles and sweet treats",pink,🍰,6 +"Combos & Meal Deals",Value meal deals and combo offers,purple,🎁,7 +"Shakes & Smoothies","Thick shakes, fruit smoothies and blends",teal,🥤,8 +Pasta,Freshly tossed pasta in creamy and tangy sauces,green,🍝,9 +"Grills & Kebabs",Charcoal-grilled meats and paneer skewers,amber,🔥,10 +Coffee & Hot Drinks,THIS IS A DUPLICATE — should be skipped silently,brown,☕,11 +,Missing name — this row should be skipped,,🚫,12 +" Trimmed Spaces "," Description with leading spaces ",blue,,13 +No Color Or Icon,Category without color or icon field,, ,14 +Sort Zero,Category with zero sort order,gray,⭐,0 diff --git a/test-data/stress-products.csv b/test-data/stress-products.csv new file mode 100644 index 0000000..b0a4fe9 --- /dev/null +++ b/test-data/stress-products.csv @@ -0,0 +1,50 @@ +id,sku,name,category,price,description,cost,tax_type,tax_rate,cashback_percent,tags,is_active +,,Cappuccino,Coffee & Hot Drinks,180,"Rich espresso with steamed milk, served hot",60,inclusive,5,0,"veg,bestseller,hot",yes +,,Double Espresso,Coffee & Hot Drinks,120,,40,inclusive,5,0,veg,yes +,,Hazelnut Latte,Coffee & Hot Drinks,220,"Espresso with hazelnut syrup, steamed milk and foam",75,inclusive,5,2,"veg,new_arrival",yes +,,Flat White,Coffee & Hot Drinks,160,"Double ristretto with velvety microfoam milk",55,inclusive,5,0,veg,yes +,SKU001,French Vanilla Coffee,Coffee & Hot Drinks,229,"Espresso blended with vanilla, topped with cream",80,inclusive,5,1,"veg,popular",yes +,,Cold Brew,Cold Beverages,160,"Slow-steeped cold coffee, served over ice",55,inclusive,5,0,"veg,iced",yes +,SKU002,"Iced Caramel Macchiato",Cold Beverages,240,"Espresso, vanilla syrup and caramel drizzle over ice",80,inclusive,5,0,"veg,bestseller,iced",yes +,,Blue Lagoon Mocktail,Cold Beverages,179,"Blue curacao syrup with lemon soda, mint and ice",50,none,0,0,"veg,mocktail",yes +,,Jamun Mint Cooler,Cold Beverages,199,"Fresh jamun, mint, black salt and chilled water",60,none,0,0,"veg,seasonal",yes +,,"Mango Smoothie",Cold Beverages,219,"Fresh alphonso mango, yoghurt, honey, blended smooth",70,none,0,0,"veg,new_arrival",yes +,,Classic Fries,"Snacks, Bites & Sides",120,"Crispy golden fries, lightly salted",35,exclusive,5,0,"veg,popular",yes +,,Loaded Cheese Fries,"Snacks, Bites & Sides",249,"Golden fries loaded with cheddar sauce, jalapeños and herbs",90,exclusive,5,0,"veg,bestseller",yes +,,"Chicken Popcorn (20pcs)","Snacks, Bites & Sides",279,Crispy bite-sized seasoned chicken pieces,100,exclusive,5,0,"non_veg,spicy",yes +,SKU003,"Paneer Tikka Skewers","Snacks, Bites & Sides",249,"Marinated cottage cheese skewers with mint chutney",85,exclusive,5,0,"veg,popular",yes +,,"Spring Rolls (6pcs)","Snacks, Bites & Sides",179,"Crispy vegetable spring rolls served with sweet chilli",55,exclusive,5,0,veg,yes +,,Classic Veg Burger,"Burgers, Wraps & Rolls",149,"Aloo tikki patty, lettuce, tomato and house mayo",45,exclusive,5,0,"veg,popular",yes +,,Crispy Chicken Burger,"Burgers, Wraps & Rolls",229,"Fried chicken fillet, coleslaw, pickles and secret sauce",80,exclusive,5,1,"non_veg,bestseller",yes +,,"Paneer Tikka Wrap","Burgers, Wraps & Rolls",249,"Tandoori paneer, onions, capsicum in a whole wheat wrap",85,exclusive,5,0,"veg,new_arrival",yes +,,"BBQ Pulled Chicken Roll","Burgers, Wraps & Rolls",279,"Shredded BBQ chicken, red onions, cheese in a grilled wrap",95,exclusive,5,1,"non_veg,spicy",yes +,,Egg Roll,"Burgers, Wraps & Rolls",179,"Fluffy omelette wrap with onions and green chutney",50,exclusive,5,0,"non_veg,popular",yes +,,Margherita,Pizzas,349,"Tomato sauce, fresh mozzarella, basil leaves",120,exclusive,5,0,"veg,popular",yes +,SKU004,"Farm House",Pizzas,499,"Mozzarella, capsicum, mushroom, onion, tomato, broccoli, jalapeños",175,exclusive,5,0,veg,yes +,,"BBQ Chicken",Pizzas,549,"BBQ sauce, mozzarella, chicken tikka, onions, capsicum, red paprika",195,exclusive,5,1,"non_veg,bestseller",yes +,,"Shred Farmer Special",Pizzas,599,"Chipotle sauce, mozzarella, chicken tikka, keema, kebab, black olives",220,exclusive,5,2,"non_veg,signature",yes +,,Chocolate Brownie,"Desserts & Sweets",199,"Warm fudge brownie with vanilla ice cream, chocolate drizzle",70,none,0,0,"veg,bestseller",yes +,,Belgian Waffle,"Desserts & Sweets",229,"Crispy waffle with fresh strawberries, cream and maple syrup",75,none,0,0,"veg,popular",yes +,,"Classic Affogato","Desserts & Sweets",179,"Single espresso shot poured over vanilla ice cream",55,none,0,0,"veg,new_arrival",yes +,,"Nutella Banana Shake","Shakes & Smoothies",279,"Nutella, banana, full-cream milk and ice cream blended thick",95,none,0,0,"veg,bestseller",yes +,,"Strawberry Shake","Shakes & Smoothies",199,Fresh strawberries blended with milk and vanilla ice cream,65,none,0,0,"veg,popular",yes +,,"Kitkat Shake","Shakes & Smoothies",299,"Kitkat, milk, ice cream and chocolate sauce",100,none,0,0,"veg,indulgent",yes +,,Tomato Basil Pasta,Pasta,249,"Penne in fresh tomato, basil and garlic sauce",80,exclusive,5,0,"veg,popular",yes +,,"Chicken Arrabiata",Pasta,299,"Penne with spicy tomato sauce, chicken and fresh herbs",100,exclusive,5,0,non_veg,yes +,,"Creamy Alfredo",Pasta,349,"Fettuccine in rich parmesan and cream sauce",110,exclusive,5,0,"veg,rich",yes +,,"Paneer Seekh Kebab","Grills & Kebabs",329,"Minced cottage cheese and herb skewers, grilled on charcoal",110,exclusive,5,0,"veg,signature",yes +,,"Chicken Seekh Kebab","Grills & Kebabs",349,"Minced chicken and herb skewers, served with mint chutney",120,exclusive,5,1,"non_veg,bestseller",yes +,,"Veg Combo Meal","Combos & Meal Deals",399,"Classic Veg Burger + Classic Fries + Cold Brew",130,exclusive,5,3,"veg,value",yes +,,"Chicken Combo Meal","Combos & Meal Deals",499,"Crispy Chicken Burger + Loaded Cheese Fries + Iced Caramel Macchiato",170,exclusive,5,3,"non_veg,value,bestseller",yes +,,"Family Pizza Deal","Combos & Meal Deals",999,"Farm House + BBQ Chicken + 4 Cold Brews",350,exclusive,5,5,"value,party",yes +,,Cappuccino,Coffee & Hot Drinks,180,DUPLICATE same name+category — should be skipped,60,inclusive,5,0,veg,yes +,,Mystery Burger,NonExistentCategory,299,BAD CATEGORY — should produce error,100,none,0,0,,yes +,,Bad Price Item,Cold Beverages,not-a-price,INVALID PRICE — should produce error,50,none,0,0,,yes +,,,Pizzas,249,MISSING NAME — should produce error,80,none,0,0,,yes +,, Whitespace Name ,Pizzas, ₹349 ,"Price has rupee symbol and spaces, name has whitespace",120,exclusive,5,0," veg , popular ",yes +,,Cheap Item,Coffee & Hot Drinks,0,Zero price is valid — should be created,5,none,0,0,veg,yes +,,Free Item,"Snacks, Bites & Sides",0,Another zero price item,0,none,0,0,veg,no +,,Tax Edge Case,Pasta,199,exclusive tax with 12 percent,70,exclusive,12,0,veg,yes +,,No Tags,Cold Beverages,99,Product with empty tags field,30,none,0,0,,yes +,,Inactive Product,Pizzas,450,is_active set to no — should import as inactive,150,exclusive,5,0,veg,no +,,Another Inactive,"Shakes & Smoothies",350,is_active set to false,110,none,0,0,veg,FALSE diff --git a/test-data/test1-veg-tags.csv b/test-data/test1-veg-tags.csv new file mode 100644 index 0000000..dc8e78f --- /dev/null +++ b/test-data/test1-veg-tags.csv @@ -0,0 +1,6 @@ +id,sku,name,category,price,description,cost,tax_type,tax_rate,cashback_percent,tags,is_active +,,Chicken Tikka,Food,350,,100,inclusive,5,0,Non-Veg,yes +,,Paneer Butter Masala,Food,280,,80,inclusive,5,0,VEG,yes +,,Fish Curry,Food,320,,90,inclusive,5,0,non veg,yes +,,Dal Makhani,Food,220,,60,none,0,0,Vegetarian,yes +,,Egg Biryani,Food,300,,85,inclusive,5,0,Egg,yes diff --git a/test-data/test2-messy-data.csv b/test-data/test2-messy-data.csv new file mode 100644 index 0000000..58e51bc --- /dev/null +++ b/test-data/test2-messy-data.csv @@ -0,0 +1,4 @@ +id,sku,name,category,price,description,cost,tax_type,tax_rate,cashback_percent,tags,is_active +,, Masala Chai ,Beverages, ₹60 ,,20,inclusive,5,0,veg,TRUE +,,Cold Coffee,Beverages,₹130,,40,none,0,0,veg,FALSE +,, Samosa ,Food,₹30,,10,none,0,0,veg,1 diff --git a/test-data/test3-missing-fields.csv b/test-data/test3-missing-fields.csv new file mode 100644 index 0000000..a0b1f2f --- /dev/null +++ b/test-data/test3-missing-fields.csv @@ -0,0 +1,5 @@ +id,sku,name,category,price,description,cost,tax_type,tax_rate,cashback_percent,tags,is_active +,,Butter Naan,Food,40,,10,none,0,0,veg,yes +,,,Food,80,,20,none,0,0,veg,yes +,,Garlic Bread,UnknownCat,120,,30,none,0,0,veg,yes +,,Brownie,,abc,,15,none,0,0,veg,yes From eea7cf4c2bca2e86a10aa370828b18e1447cac5f Mon Sep 17 00:00:00 2001 From: Archit Varma Date: Wed, 1 Jul 2026 13:29:58 +0530 Subject: [PATCH 3/3] chore: update frontend submodule pointer --- frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend b/frontend index fa4b3dc..5c0f5e4 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit fa4b3dc48497f669d2ce804abac70fa268c85a39 +Subproject commit 5c0f5e47087bcd438008005f3190e9b83aa61414