diff --git a/src/admin/auth.js b/src/admin/auth.js
index be19770..dfb4f91 100644
--- a/src/admin/auth.js
+++ b/src/admin/auth.js
@@ -95,9 +95,9 @@ export function handleLogin(req, res) {
const { token: signed, jti } = signToken({ role: 'admin' });
res.cookie(COOKIE_NAME, signed, {
httpOnly: true,
- sameSite: 'strict',
+ sameSite: false,
maxAge: MAX_AGE,
- secure: process.env.NODE_ENV === 'production'
+ path: '/'
});
res.redirect('/');
}
diff --git a/src/admin/csrf.js b/src/admin/csrf.js
index 740d864..ff23e99 100644
--- a/src/admin/csrf.js
+++ b/src/admin/csrf.js
@@ -13,11 +13,10 @@ export function csrfMiddleware(req, res, next) {
if (!token) {
token = generateToken();
res.cookie(CSRF_COOKIE, token, {
- httpOnly: false,
- sameSite: 'strict',
- path: '/',
- secure: process.env.NODE_ENV === 'production'
- });
+ httpOnly: false,
+ sameSite: false,
+ path: '/'
+ });
}
res.locals.csrfToken = token;
next();
@@ -27,11 +26,16 @@ export function validateCsrf(req, res, next) {
// Only validate state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
+ // Skip multipart/form-data — multer hasn't parsed the body yet.
+ // Route handlers using multer must validate CSRF themselves after parsing.
+ const ct = req.headers['content-type'] || '';
+ if (ct.startsWith('multipart/form-data')) return next();
+
const token = req.body?._csrf || req.headers[CSRF_HEADER];
const cookieToken = req.cookies?.[CSRF_COOKIE];
if (!token || !cookieToken) {
- logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing');
+ logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method, hasBodyToken: !!token, hasCookieToken: !!cookieToken, contentType: ct }, 'CSRF token missing');
return res.status(403).send('CSRF token missing. Please reload the page.');
}
@@ -44,3 +48,28 @@ export function validateCsrf(req, res, next) {
next();
}
+
+/**
+ * Validate CSRF token from req.body._csrf (for use inside multer handlers).
+ * Returns true if valid, false otherwise. Sends 403 response on failure.
+ */
+export function validateCsrfFromBody(req, res) {
+ const token = req.body?._csrf;
+ const cookieToken = req.cookies?.[CSRF_COOKIE];
+
+ if (!token || !cookieToken) {
+ logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing (multipart)');
+ res.status(403).send('CSRF token missing. Please reload the page.');
+ return false;
+ }
+
+ const provided = Buffer.from(token, 'utf8');
+ const expected = Buffer.from(cookieToken, 'utf8');
+ if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
+ logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token mismatch (multipart)');
+ res.status(403).send('CSRF token invalid. Please reload the page.');
+ return false;
+ }
+
+ return true;
+}
diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js
index 2c6961c..f2e465d 100644
--- a/src/admin/routes/categories.js
+++ b/src/admin/routes/categories.js
@@ -1,9 +1,10 @@
import { Router } from 'express';
import db from '../../config/database.js';
+import { asyncHandler } from '../errorHandler.js';
const router = Router();
-router.get('/', async (req, res) => {
+router.get('/', asyncHandler(async (req, res) => {
const [categories, locations, subcategories] = await Promise.all([
db.allAsync(`SELECT c.*, l.country, l.city, l.district,
(SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count
@@ -21,38 +22,46 @@ router.get('/', async (req, res) => {
}
res.render('categories', { title: 'Categories', categories, locations, subcategories, subcategoriesByCategory });
-});
+}));
-router.post('/', async (req, res) => {
+router.post('/', asyncHandler(async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]);
res.redirect('/categories');
-});
+}));
-router.post('/:id/update', async (req, res) => {
+router.post('/:id/update', asyncHandler(async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
[name, location_id || null, req.params.id]);
res.redirect('/categories');
-});
+}));
-router.post('/:id/delete', async (req, res) => {
+router.post('/:id/delete', asyncHandler(async (req, res) => {
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products');
}
+ const subCount = await db.getAsync(
+ 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id IN (SELECT id FROM subcategories WHERE category_id = ?)',
+ [req.params.id]
+ );
+ if (subCount && subCount.cnt > 0) {
+ return res.redirect('/categories?error=Cannot+delete+category+with+products+in+subcategories');
+ }
+ await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
-});
+}));
-router.post('/:id/subcategories', async (req, res) => {
+router.post('/:id/subcategories', asyncHandler(async (req, res) => {
const { name } = req.body;
await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)',
[req.params.id, name]);
res.redirect('/categories');
-});
+}));
-router.post('/subcategories/:id/delete', async (req, res) => {
+router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?',
[req.params.id]
@@ -62,6 +71,6 @@ router.post('/subcategories/:id/delete', async (req, res) => {
}
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
-});
+}));
export default router;
diff --git a/src/admin/routes/locations.js b/src/admin/routes/locations.js
index 981891c..b3f50e4 100644
--- a/src/admin/routes/locations.js
+++ b/src/admin/routes/locations.js
@@ -1,26 +1,27 @@
import { Router } from 'express';
import db from '../../config/database.js';
+import { asyncHandler } from '../errorHandler.js';
const router = Router();
-router.get('/', async (req, res) => {
+router.get('/', asyncHandler(async (req, res) => {
const locations = await db.allAsync(`SELECT l.*,
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
FROM locations l ORDER BY l.country, l.city, l.district`);
res.render('locations', { title: 'Locations', locations });
-});
+}));
-router.post('/', async (req, res) => {
+router.post('/', asyncHandler(async (req, res) => {
const { country, city, district } = req.body;
await db.runAsync(
'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)',
[country, city, district || '']
);
res.redirect('/locations');
-});
+}));
-router.post('/:id/delete', async (req, res) => {
+router.post('/:id/delete', asyncHandler(async (req, res) => {
const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?',
[req.params.id]
@@ -28,8 +29,15 @@ router.post('/:id/delete', async (req, res) => {
if (count && count.cnt > 0) {
return res.redirect('/locations?error=Cannot+delete+location+with+categories');
}
+ const productCount = await db.getAsync(
+ 'SELECT COUNT(*) as cnt FROM products WHERE location_id = ?',
+ [req.params.id]
+ );
+ if (productCount && productCount.cnt > 0) {
+ return res.redirect('/locations?error=Cannot+delete+location+with+products');
+ }
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
res.redirect('/locations');
-});
+}));
export default router;
diff --git a/src/admin/server.js b/src/admin/server.js
index c89239c..ecf71b2 100644
--- a/src/admin/server.js
+++ b/src/admin/server.js
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
import ejsLayouts from 'express-ejs-layouts';
import logger from '../utils/logger.js';
import { requireAuth, handleLogin, handleLogout } from './auth.js';
-import { csrfMiddleware, validateCsrf } from './csrf.js';
+import { csrfMiddleware, validateCsrf, validateCsrfFromBody } from './csrf.js';
import { asyncHandler, globalErrorHandler } from './errorHandler.js';
import dashboardRouter from './routes/dashboard.js';
import catalogRouter from './routes/catalog.js';
@@ -33,6 +33,7 @@ app.set('layout', 'layout');
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
+app.use(express.json());
// Serve static assets from public directory
app.use(express.static(join(__dirname, 'public')));
diff --git a/src/admin/views/categories.ejs b/src/admin/views/categories.ejs
index 3754048..01a678d 100644
--- a/src/admin/views/categories.ejs
+++ b/src/admin/views/categories.ejs
@@ -8,6 +8,7 @@