This commit is contained in:
Local Server
2025-12-19 20:44:46 -06:00
parent 701f799cde
commit e4b3de4a46
113 changed files with 16673 additions and 2174 deletions

View File

@@ -1,19 +1,32 @@
const logger = require("../config/logger");
const { sendUnauthorized, sendForbidden } = require("../utils/responseHelpers");
const isAuthenticated = (req) => {
return req.session?.user?.id;
};
const requireAuth = (req, res, next) => {
if (req.session && req.session.user && req.session.user.id) {
if (isAuthenticated(req)) {
return next();
}
res.status(401).json({ success: false, message: "Authentication required" });
logger.warn("Unauthorized access attempt", {
path: req.path,
ip: req.ip,
});
sendUnauthorized(res);
};
const requireRole = (allowedRoles) => {
// Allow single role or array of roles
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
return (req, res, next) => {
if (!req.session || !req.session.user || !req.session.user.id) {
return res
.status(401)
.json({ success: false, message: "Authentication required" });
if (!isAuthenticated(req)) {
logger.warn("Unauthorized access attempt", {
path: req.path,
ip: req.ip,
});
return sendUnauthorized(res);
}
const userRole = req.session.user.role_id || "role-admin";
@@ -22,12 +35,14 @@ const requireRole = (allowedRoles) => {
return next();
}
res.status(403).json({
success: false,
message: "Access denied. Insufficient permissions.",
required_role: roles,
your_role: userRole,
logger.warn("Forbidden access attempt", {
path: req.path,
ip: req.ip,
userRole,
requiredRoles: roles,
});
sendForbidden(res, "Access denied. Insufficient permissions.");
};
};

View File

@@ -0,0 +1,109 @@
const logger = require("../config/logger");
const {
isDevelopment,
PG_ERROR_CODES,
MULTER_ERROR_CODES,
STATIC_ASSET_EXTENSIONS,
} = require("../config/constants");
class AppError extends Error {
constructor(message, statusCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.timestamp = new Date().toISOString();
Error.captureStackTrace(this, this.constructor);
}
}
const ERROR_MAPPINGS = {
[PG_ERROR_CODES.UNIQUE_VIOLATION]: {
message: "Duplicate entry: Resource already exists",
statusCode: 409,
},
[PG_ERROR_CODES.FOREIGN_KEY_VIOLATION]: {
message: "Referenced resource does not exist",
statusCode: 400,
},
[PG_ERROR_CODES.INVALID_TEXT]: {
message: "Invalid data format",
statusCode: 400,
},
[MULTER_ERROR_CODES.FILE_SIZE]: {
message: "File too large. Maximum size is 5MB",
statusCode: 400,
},
[MULTER_ERROR_CODES.FILE_COUNT]: {
message: "Too many files. Maximum is 10 files per upload",
statusCode: 400,
},
};
// Global error handler middleware
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
error.statusCode = err.statusCode || 500;
// Log error
logger.error("Error occurred", {
message: error.message,
statusCode: error.statusCode,
path: req.path,
method: req.method,
ip: req.ip,
stack: err.stack,
});
// Map known error codes
const errorMapping = ERROR_MAPPINGS[err.code];
if (errorMapping) {
error.message = errorMapping.message;
error.statusCode = errorMapping.statusCode;
}
res.status(error.statusCode).json({
success: false,
message: error.message || "Server error",
...(isDevelopment() && {
error: err.message,
stack: err.stack,
}),
});
};
// 404 handler
const notFoundHandler = (req, res) => {
const isStaticAsset = STATIC_ASSET_EXTENSIONS.test(req.path);
if (!isStaticAsset) {
logger.warn("Route not found", {
path: req.path,
method: req.method,
ip: req.ip,
});
} else {
logger.debug("Static asset not found", {
path: req.path,
method: req.method,
});
}
res.status(404).json({
success: false,
message: "Route not found",
path: req.path,
});
};
// Async handler wrapper to catch errors in async routes
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = {
AppError,
errorHandler,
notFoundHandler,
asyncHandler,
};

View File

@@ -0,0 +1,161 @@
const { body, param, query, validationResult } = require("express-validator");
const logger = require("../config/logger");
// Validation error handler middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn("Validation error", {
path: req.path,
errors: errors.array(),
body: req.body,
});
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array().map((err) => ({
field: err.param,
message: err.msg,
})),
});
}
next();
};
// Common validation rules
const validators = {
// Auth validators
login: [
body("email")
.isEmail()
.withMessage("Valid email is required")
.normalizeEmail()
.trim(),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters"),
],
// User validators
createUser: [
body("email")
.isEmail()
.withMessage("Valid email is required")
.normalizeEmail()
.trim(),
body("username")
.isLength({ min: 3, max: 50 })
.matches(/^[a-zA-Z0-9_-]+$/)
.withMessage(
"Username must be 3-50 characters and contain only letters, numbers, hyphens, and underscores"
)
.trim(),
body("password")
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage(
"Password must be at least 8 characters with uppercase, lowercase, and number"
),
body("role_id").notEmpty().withMessage("Role is required").trim(),
],
updateUser: [
param("id")
.matches(/^user-[a-f0-9-]+$/)
.withMessage("Invalid user ID format"),
body("email")
.optional()
.isEmail()
.withMessage("Valid email is required")
.normalizeEmail()
.trim(),
body("username")
.optional()
.isLength({ min: 3, max: 50 })
.withMessage("Username must be 3-50 characters")
.matches(/^[a-zA-Z0-9_-]+$/)
.trim(),
],
// Product validators
createProduct: [
body("name")
.isLength({ min: 1, max: 255 })
.withMessage("Product name is required (max 255 characters)")
.trim()
.escape(),
body("description")
.optional()
.isString()
.withMessage("Description must be text")
.trim(),
body("price")
.isFloat({ min: 0 })
.withMessage("Price must be a positive number"),
body("stockquantity")
.optional()
.isInt({ min: 0 })
.withMessage("Stock quantity must be a non-negative integer"),
body("category")
.optional()
.isString()
.withMessage("Category must be text")
.trim()
.escape(),
],
updateProduct: [
param("id").isUUID().withMessage("Invalid product ID"),
body("name")
.optional()
.isLength({ min: 1, max: 255 })
.withMessage("Product name must be 1-255 characters")
.trim()
.escape(),
body("price")
.optional()
.isFloat({ min: 0 })
.withMessage("Price must be a positive number"),
body("stockquantity")
.optional()
.isInt({ min: 0 })
.withMessage("Stock quantity must be a non-negative integer"),
],
// Blog validators
createBlogPost: [
body("title")
.isLength({ min: 1, max: 255 })
.withMessage("Title is required (max 255 characters)")
.trim()
.escape(),
body("slug")
.isLength({ min: 1, max: 255 })
.matches(/^[a-z0-9-]+$/)
.withMessage(
"Slug must contain only lowercase letters, numbers, and hyphens"
)
.trim(),
body("content").notEmpty().withMessage("Content is required").trim(),
],
// Generic ID validator
idParam: [param("id").notEmpty().withMessage("ID is required").trim()],
// Pagination validators
pagination: [
query("page")
.optional()
.isInt({ min: 1 })
.withMessage("Page must be a positive integer"),
query("limit")
.optional()
.isInt({ min: 1, max: 100 })
.withMessage("Limit must be between 1 and 100"),
],
};
module.exports = {
validators,
handleValidationErrors,
};