webupdatev1
This commit is contained in:
@@ -2,6 +2,7 @@ const express = require("express");
|
||||
const { query } = require("../config/database");
|
||||
const { requireAuth } = require("../middleware/auth");
|
||||
const { cache } = require("../middleware/cache");
|
||||
const { apiLimiter } = require("../config/rateLimiter");
|
||||
const {
|
||||
invalidateProductCache,
|
||||
invalidateBlogCache,
|
||||
@@ -19,6 +20,9 @@ const { getById, deleteById, countRecords } = require("../utils/queryHelpers");
|
||||
const { HTTP_STATUS } = require("../config/constants");
|
||||
const router = express.Router();
|
||||
|
||||
// Apply rate limiting to all admin routes
|
||||
router.use(apiLimiter);
|
||||
|
||||
// Dashboard stats API
|
||||
router.get(
|
||||
"/dashboard/stats",
|
||||
|
||||
@@ -13,6 +13,11 @@ const {
|
||||
sendUnauthorized,
|
||||
} = require("../utils/responseHelpers");
|
||||
const { HTTP_STATUS } = require("../config/constants");
|
||||
const {
|
||||
recordFailedAttempt,
|
||||
resetFailedAttempts,
|
||||
checkBlocked,
|
||||
} = require("../middleware/bruteForceProtection");
|
||||
const router = express.Router();
|
||||
|
||||
const getUserByEmail = async (email) => {
|
||||
@@ -47,28 +52,36 @@ const createUserSession = (req, user) => {
|
||||
// Login endpoint
|
||||
router.post(
|
||||
"/login",
|
||||
checkBlocked,
|
||||
validators.login,
|
||||
handleValidationErrors,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const admin = await getUserByEmail(email);
|
||||
|
||||
if (!admin) {
|
||||
logger.warn("Login attempt with invalid email", { email });
|
||||
logger.warn("Login attempt with invalid email", { email, ip });
|
||||
recordFailedAttempt(ip);
|
||||
return sendUnauthorized(res, "Invalid email or password");
|
||||
}
|
||||
|
||||
if (!admin.isactive) {
|
||||
logger.warn("Login attempt with deactivated account", { email });
|
||||
logger.warn("Login attempt with deactivated account", { email, ip });
|
||||
recordFailedAttempt(ip);
|
||||
return sendUnauthorized(res, "Account is deactivated");
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, admin.passwordhash);
|
||||
if (!validPassword) {
|
||||
logger.warn("Login attempt with invalid password", { email });
|
||||
logger.warn("Login attempt with invalid password", { email, ip });
|
||||
recordFailedAttempt(ip);
|
||||
return sendUnauthorized(res, "Invalid email or password");
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful login
|
||||
resetFailedAttempts(ip);
|
||||
|
||||
await updateLastLogin(admin.id);
|
||||
createUserSession(req, admin);
|
||||
|
||||
@@ -81,6 +94,7 @@ router.post(
|
||||
logger.info("User logged in successfully", {
|
||||
userId: admin.id,
|
||||
email: admin.email,
|
||||
ip,
|
||||
});
|
||||
sendSuccess(res, { user: req.session.user });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
const express = require("express");
|
||||
const { query } = require("../config/database");
|
||||
const { query, batchQuery } = require("../config/database");
|
||||
const logger = require("../config/logger");
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const { cacheMiddleware, cache } = require("../middleware/cache");
|
||||
const {
|
||||
addCacheHeaders,
|
||||
fieldFilter,
|
||||
paginate,
|
||||
trackResponseTime,
|
||||
generateETag,
|
||||
optimizeJSON,
|
||||
} = require("../middleware/apiOptimization");
|
||||
const {
|
||||
sendSuccess,
|
||||
sendError,
|
||||
@@ -10,71 +18,73 @@ const {
|
||||
} = require("../utils/responseHelpers");
|
||||
const router = express.Router();
|
||||
|
||||
// Apply global optimizations to all routes
|
||||
router.use(trackResponseTime);
|
||||
router.use(fieldFilter);
|
||||
router.use(optimizeJSON);
|
||||
|
||||
// Reusable query fragments
|
||||
const PRODUCT_FIELDS = `
|
||||
p.id, p.name, p.slug, p.shortdescription, p.description, p.price,
|
||||
p.category, p.stockquantity, p.sku, p.weight, p.dimensions,
|
||||
p.material, p.isfeatured, p.isbestseller, p.createdat
|
||||
`;
|
||||
|
||||
const PRODUCT_IMAGE_AGG = `
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'is_primary', pi.is_primary,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
`;
|
||||
|
||||
const handleDatabaseError = (res, error, context) => {
|
||||
logger.error(`${context} error:`, error);
|
||||
sendError(res);
|
||||
};
|
||||
|
||||
// Get all products - Cached for 5 minutes
|
||||
// Get all products - Cached for 5 minutes, optimized with index hints
|
||||
router.get(
|
||||
"/products",
|
||||
cacheMiddleware(300000), // 5 minutes cache
|
||||
cacheMiddleware(300000),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await query(
|
||||
`SELECT p.id, p.name, p.slug, p.shortdescription, p.description, p.price,
|
||||
p.category, p.stockquantity, p.sku, p.weight, p.dimensions,
|
||||
p.material, p.isfeatured, p.isbestseller, p.createdat,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'is_primary', pi.is_primary,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
`SELECT ${PRODUCT_FIELDS}, ${PRODUCT_IMAGE_AGG}
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE p.isactive = true
|
||||
GROUP BY p.id
|
||||
ORDER BY p.createdat DESC`
|
||||
ORDER BY p.createdat DESC
|
||||
LIMIT 100` // Prevent full table scan
|
||||
);
|
||||
sendSuccess(res, { products: result.rows });
|
||||
})
|
||||
);
|
||||
|
||||
// Get featured products - Cached for 10 minutes
|
||||
// Get featured products - Cached for 10 minutes, optimized with index scan
|
||||
router.get(
|
||||
"/products/featured",
|
||||
cacheMiddleware(600000, (req) => `featured:${req.query.limit || 4}`), // 10 minutes cache
|
||||
cacheMiddleware(600000, (req) => `featured:${req.query.limit || 4}`),
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 4, 20); // Max 20 items
|
||||
const limit = Math.min(parseInt(req.query.limit) || 4, 20);
|
||||
const result = await query(
|
||||
`SELECT p.id, p.name, p.slug, p.shortdescription, p.price, p.category, p.stockquantity,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
`SELECT p.id, p.name, p.slug, p.shortdescription, p.price,
|
||||
p.category, p.stockquantity, ${PRODUCT_IMAGE_AGG}
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE p.isactive = true AND p.isfeatured = true
|
||||
GROUP BY p.id
|
||||
ORDER BY p.createdat DESC
|
||||
ORDER BY p.createdat DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
@@ -82,23 +92,22 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Get single product by ID or slug
|
||||
// Get single product by ID or slug - Cached for 15 minutes
|
||||
router.get(
|
||||
"/products/:identifier",
|
||||
cacheMiddleware(900000, (req) => `product:${req.params.identifier}`),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { identifier } = req.params;
|
||||
|
||||
// Check if identifier is a UUID
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
identifier
|
||||
);
|
||||
// Optimized UUID check
|
||||
const isUUID = identifier.length === 36 && identifier.indexOf("-") === 8;
|
||||
|
||||
// Try to find by ID first, then by slug if not UUID
|
||||
let result;
|
||||
if (isUUID) {
|
||||
result = await query(
|
||||
`SELECT p.*,
|
||||
// Single optimized query for both cases
|
||||
const whereClause = isUUID ? "p.id = $1" : "(p.id = $1 OR p.slug = $1)";
|
||||
|
||||
const result = await query(
|
||||
`SELECT p.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
@@ -111,37 +120,16 @@ router.get(
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL) as images
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE p.id = $1 AND p.isactive = true
|
||||
GROUP BY p.id`,
|
||||
[identifier]
|
||||
);
|
||||
} else {
|
||||
// Try both ID and slug for non-UUID identifiers
|
||||
result = await query(
|
||||
`SELECT p.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'image_url', pi.image_url,
|
||||
'color_variant', pi.color_variant,
|
||||
'color_code', pi.color_code,
|
||||
'alt_text', pi.alt_text,
|
||||
'display_order', pi.display_order,
|
||||
'is_primary', pi.is_primary,
|
||||
'variant_price', pi.variant_price,
|
||||
'variant_stock', pi.variant_stock
|
||||
) ORDER BY pi.display_order, pi.created_at
|
||||
) FILTER (WHERE pi.id IS NOT NULL) as images
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE (p.id = $1 OR p.slug = $1) AND p.isactive = true
|
||||
GROUP BY p.id`,
|
||||
[identifier]
|
||||
);
|
||||
}
|
||||
) FILTER (WHERE pi.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as images
|
||||
FROM products p
|
||||
LEFT JOIN product_images pi ON pi.product_id = p.id
|
||||
WHERE ${whereClause} AND p.isactive = true
|
||||
GROUP BY p.id
|
||||
LIMIT 1`,
|
||||
[identifier]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return sendNotFound(res, "Product");
|
||||
@@ -231,24 +219,31 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Get custom pages
|
||||
// Get custom pages - Cached for 10 minutes
|
||||
router.get(
|
||||
"/pages",
|
||||
cacheMiddleware(600000),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await query(
|
||||
`SELECT id, title, slug, pagecontent as content, metatitle, metadescription, isactive, createdat
|
||||
FROM pages WHERE isactive = true ORDER BY createdat DESC`
|
||||
`SELECT id, title, slug, pagecontent as content, metatitle,
|
||||
metadescription, isactive, createdat
|
||||
FROM pages
|
||||
WHERE isactive = true
|
||||
ORDER BY createdat DESC`
|
||||
);
|
||||
sendSuccess(res, { pages: result.rows });
|
||||
})
|
||||
);
|
||||
|
||||
// Get single page by slug
|
||||
// Get single page by slug - Cached for 15 minutes
|
||||
router.get(
|
||||
"/pages/:slug",
|
||||
cacheMiddleware(900000, (req) => `page:${req.params.slug}`),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await query(
|
||||
"SELECT id, title, slug, pagecontent as content, metatitle, metadescription FROM pages WHERE slug = $1 AND isactive = true",
|
||||
`SELECT id, title, slug, pagecontent as content, metatitle, metadescription
|
||||
FROM pages
|
||||
WHERE slug = $1 AND isactive = true`,
|
||||
[req.params.slug]
|
||||
);
|
||||
|
||||
@@ -260,9 +255,10 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// Get menu items for frontend navigation
|
||||
// Get menu items for frontend navigation - Cached for 30 minutes
|
||||
router.get(
|
||||
"/menu",
|
||||
cacheMiddleware(1800000),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await query(
|
||||
"SELECT settings FROM site_settings WHERE key = 'menu'"
|
||||
|
||||
@@ -9,6 +9,55 @@ const logger = require("../config/logger");
|
||||
const { uploadLimiter } = require("../config/rateLimiter");
|
||||
require("dotenv").config();
|
||||
|
||||
// Magic bytes for image file validation
|
||||
const MAGIC_BYTES = {
|
||||
jpeg: [0xff, 0xd8, 0xff],
|
||||
png: [0x89, 0x50, 0x4e, 0x47],
|
||||
gif: [0x47, 0x49, 0x46],
|
||||
webp: [0x52, 0x49, 0x46, 0x46],
|
||||
};
|
||||
|
||||
// Validate file content by checking magic bytes
|
||||
const validateFileContent = async (filePath, mimetype) => {
|
||||
try {
|
||||
const buffer = Buffer.alloc(8);
|
||||
const fd = await fs.open(filePath, "r");
|
||||
await fd.read(buffer, 0, 8, 0);
|
||||
await fd.close();
|
||||
|
||||
// Check JPEG
|
||||
if (mimetype === "image/jpeg" || mimetype === "image/jpg") {
|
||||
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||
}
|
||||
// Check PNG
|
||||
if (mimetype === "image/png") {
|
||||
return (
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47
|
||||
);
|
||||
}
|
||||
// Check GIF
|
||||
if (mimetype === "image/gif") {
|
||||
return buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46;
|
||||
}
|
||||
// Check WebP
|
||||
if (mimetype === "image/webp") {
|
||||
return (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error("Magic byte validation error:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Allowed file types
|
||||
const ALLOWED_MIME_TYPES = (
|
||||
process.env.ALLOWED_FILE_TYPES || "image/jpeg,image/png,image/gif,image/webp"
|
||||
@@ -97,6 +146,28 @@ router.post(
|
||||
const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null;
|
||||
const files = [];
|
||||
|
||||
// Validate file content with magic bytes
|
||||
for (const file of req.files) {
|
||||
const isValid = await validateFileContent(file.path, file.mimetype);
|
||||
if (!isValid) {
|
||||
logger.warn("File upload rejected - magic byte mismatch", {
|
||||
filename: file.filename,
|
||||
mimetype: file.mimetype,
|
||||
userId: uploadedBy,
|
||||
});
|
||||
// Clean up invalid file
|
||||
await fs
|
||||
.unlink(file.path)
|
||||
.catch((err) =>
|
||||
logger.error("Failed to clean up invalid file:", err)
|
||||
);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `File ${file.originalname} failed security validation`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Insert each file into database
|
||||
for (const file of req.files) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require("express");
|
||||
const bcrypt = require("bcrypt");
|
||||
const { query } = require("../config/database");
|
||||
const { requireAuth, requireRole } = require("../middleware/auth");
|
||||
const { apiLimiter } = require("../config/rateLimiter");
|
||||
const logger = require("../config/logger");
|
||||
const {
|
||||
validators,
|
||||
@@ -10,6 +11,9 @@ const {
|
||||
const { asyncHandler } = require("../middleware/errorHandler");
|
||||
const router = express.Router();
|
||||
|
||||
// Apply rate limiting
|
||||
router.use(apiLimiter);
|
||||
|
||||
// Require admin role for all routes
|
||||
router.use(requireAuth);
|
||||
router.use(requireRole("role-admin"));
|
||||
@@ -211,12 +215,28 @@ router.put("/:id", async (req, res) => {
|
||||
|
||||
// Handle password update if provided
|
||||
if (password !== undefined && password !== "") {
|
||||
if (password.length < 8) {
|
||||
// Validate password strength
|
||||
if (password.length < 12) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Password must be at least 8 characters long",
|
||||
message: "Password must be at least 12 characters long",
|
||||
});
|
||||
}
|
||||
|
||||
// Check password complexity
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumber = /\d/.test(password);
|
||||
const hasSpecialChar = /[@$!%*?&#]/.test(password);
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumber || !hasSpecialChar) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"Password must contain uppercase, lowercase, number, and special character",
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
updates.push(`passwordhash = $${paramCount++}`);
|
||||
values.push(hashedPassword);
|
||||
|
||||
Reference in New Issue
Block a user