webupdate

This commit is contained in:
Local Server
2026-01-18 02:22:05 -06:00
parent 6fc159051a
commit 2a2a3d99e5
135 changed files with 54897 additions and 9825 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,14 @@ const {
} = require("../middleware/bruteForceProtection");
const router = express.Router();
const getUserByEmail = async (email) => {
const getUserByEmailOrUsername = async (emailOrUsername) => {
const result = await query(
`SELECT u.id, u.email, u.username, u.passwordhash, u.role_id, u.isactive,
r.name as role_name, r.permissions
FROM adminusers u
LEFT JOIN roles r ON u.role_id = r.id
WHERE u.email = $1`,
[email]
WHERE u.email = $1 OR u.username = $1`,
[emailOrUsername],
);
return result.rows[0] || null;
};
@@ -58,10 +58,10 @@ router.post(
asyncHandler(async (req, res) => {
const { email, password } = req.body;
const ip = req.ip || req.connection.remoteAddress;
const admin = await getUserByEmail(email);
const admin = await getUserByEmailOrUsername(email);
if (!admin) {
logger.warn("Login attempt with invalid email", { email, ip });
logger.warn("Login attempt with invalid email/username", { email, ip });
recordFailedAttempt(ip);
return sendUnauthorized(res, "Invalid email or password");
}
@@ -98,7 +98,7 @@ router.post(
});
sendSuccess(res, { user: req.session.user });
});
})
}),
);
// Check session endpoint

View File

@@ -0,0 +1,662 @@
const express = require("express");
const router = express.Router();
const bcrypt = require("bcrypt");
const nodemailer = require("nodemailer");
const { pool } = require("../config/database");
const logger = require("../config/logger");
const rateLimit = require("express-rate-limit");
// SECURITY: Rate limiting for auth endpoints to prevent brute force
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 failed attempts per window before lockout
skipSuccessfulRequests: true, // Only count failed attempts
message: {
success: false,
message:
"Too many failed login attempts, please try again after 15 minutes",
},
standardHeaders: true,
legacyHeaders: false,
});
const signupRateLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 signups per hour per IP (increased for production)
message: {
success: false,
message: "Too many signup attempts, please try again later",
},
});
const resendCodeLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 3, // 3 resends per minute
message: {
success: false,
message: "Please wait before requesting another code",
},
});
// SECURITY: HTML escape function to prevent XSS in emails
const escapeHtml = (str) => {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
// SECURITY: Use crypto for secure verification code generation
const crypto = require("crypto");
// Email transporter configuration
let transporter = null;
if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
}
// Generate 6-digit verification code using cryptographically secure random
function generateVerificationCode() {
// SECURITY: Use crypto.randomInt for secure random number generation
return crypto.randomInt(100000, 999999).toString();
}
// Send verification email
async function sendVerificationEmail(email, code, firstName) {
if (!transporter) {
logger.warn("SMTP not configured - verification code logged instead");
logger.info(`🔐 Verification code for ${email}: ${code}`);
return true;
}
// SECURITY: Escape user input to prevent XSS in emails
const safeName = escapeHtml(firstName) || "there";
const safeCode = String(code).replace(/[^0-9]/g, ""); // Only allow digits
const mailOptions = {
from: process.env.SMTP_FROM || '"Sky Art Shop" <noreply@skyartshop.com>',
to: email,
subject: "Verify your Sky Art Shop account",
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #e91e63 0%, #9c27b0 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.code { font-size: 36px; font-weight: bold; color: #e91e63; letter-spacing: 8px; text-align: center; padding: 20px; background: white; border-radius: 8px; margin: 20px 0; }
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎨 Sky Art Shop</h1>
<p>Welcome to our creative community!</p>
</div>
<div class="content">
<p>Hi ${safeName}!</p>
<p>Thank you for creating an account with Sky Art Shop. Please use the verification code below to complete your registration:</p>
<div class="code">${safeCode}</div>
<p>This code will expire in <strong>15 minutes</strong>.</p>
<p>If you didn't create this account, please ignore this email.</p>
</div>
<div class="footer">
<p>© 2025 Sky Art Shop. All rights reserved.</p>
<p>Your one-stop shop for scrapbooking, journaling, and creative stationery.</p>
</div>
</div>
</body>
</html>
`,
};
try {
await transporter.sendMail(mailOptions);
logger.info(`Verification email sent to ${email}`);
return true;
} catch (error) {
logger.error("Error sending verification email:", error);
// Still log code as fallback
logger.info(`🔐 Verification code for ${email}: ${code}`);
return false;
}
}
// Customer auth middleware - session based
const requireCustomerAuth = (req, res, next) => {
if (!req.session || !req.session.customerId) {
return res
.status(401)
.json({ success: false, message: "Please login to continue" });
}
next();
};
// ===========================
// SIGNUP - Create new customer
// ===========================
router.post("/signup", signupRateLimiter, async (req, res) => {
try {
const {
firstName,
lastName,
email,
password,
newsletterSubscribed = false,
} = req.body;
// Validation
if (!firstName || !lastName || !email || !password) {
return res.status(400).json({
success: false,
message:
"All fields are required (firstName, lastName, email, password)",
});
}
// SECURITY: Sanitize inputs to prevent XSS
const sanitizedFirstName = firstName
.replace(/[<>"'&]/g, "")
.trim()
.substring(0, 50);
const sanitizedLastName = lastName
.replace(/[<>"'&]/g, "")
.trim()
.substring(0, 50);
// Email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: "Please enter a valid email address",
});
}
// Password strength validation
if (password.length < 8) {
return res.status(400).json({
success: false,
message: "Password must be at least 8 characters long",
});
}
// Check if email already exists
const existingCustomer = await pool.query(
"SELECT id, email_verified FROM customers WHERE email = $1",
[email.toLowerCase()],
);
if (existingCustomer.rows.length > 0) {
const customer = existingCustomer.rows[0];
// If email exists but not verified, allow re-registration
if (!customer.email_verified) {
// Generate new verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
const passwordHash = await bcrypt.hash(password, 12);
await pool.query(
`UPDATE customers
SET first_name = $1, last_name = $2, password_hash = $3,
verification_code = $4, verification_code_expires = $5,
updated_at = CURRENT_TIMESTAMP
WHERE email = $6`,
[
firstName,
lastName,
passwordHash,
verificationCode,
expiresAt,
email.toLowerCase(),
],
);
// Send verification email
await sendVerificationEmail(email, verificationCode, firstName);
// Store email in session for verification step
req.session.pendingVerificationEmail = email.toLowerCase();
return res.json({
success: true,
message:
"Verification code sent to your email. Please check your inbox.",
requiresVerification: true,
});
}
return res.status(400).json({
success: false,
message:
"An account with this email already exists. Please login instead.",
});
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Generate verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
// Create customer
await pool.query(
`INSERT INTO customers (first_name, last_name, email, password_hash, verification_code, verification_code_expires, newsletter_subscribed)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email`,
[
firstName,
lastName,
email.toLowerCase(),
passwordHash,
verificationCode,
expiresAt,
newsletterSubscribed,
],
);
// Send verification email
await sendVerificationEmail(email, verificationCode, firstName);
// Store email in session for verification step
req.session.pendingVerificationEmail = email.toLowerCase();
logger.info(`New customer signup: ${email}`);
res.json({
success: true,
message:
"Account created! Please check your email for the verification code.",
requiresVerification: true,
});
} catch (error) {
logger.error("Signup error:", error);
res.status(500).json({
success: false,
message: "Failed to create account. Please try again.",
});
}
});
// ===========================
// VERIFY EMAIL
// ===========================
router.post("/verify-email", async (req, res) => {
try {
const { email, code } = req.body;
const emailToVerify =
email?.toLowerCase() || req.session.pendingVerificationEmail;
if (!emailToVerify || !code) {
return res.status(400).json({
success: false,
message: "Email and verification code are required",
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email_verified, verification_code, verification_code_expires
FROM customers WHERE email = $1`,
[emailToVerify],
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: "Account not found. Please sign up first.",
});
}
const customer = result.rows[0];
if (customer.email_verified) {
return res.json({
success: true,
message: "Email already verified. You can now login.",
alreadyVerified: true,
});
}
if (customer.verification_code !== code) {
return res.status(400).json({
success: false,
message: "Invalid verification code. Please try again.",
});
}
if (new Date() > new Date(customer.verification_code_expires)) {
return res.status(400).json({
success: false,
message: "Verification code has expired. Please request a new one.",
expired: true,
});
}
// Mark email as verified
await pool.query(
`UPDATE customers
SET email_verified = TRUE, verification_code = NULL, verification_code_expires = NULL,
last_login = CURRENT_TIMESTAMP, login_count = 1
WHERE id = $1`,
[customer.id],
);
// Set session - auto-login after verification
req.session.customerId = customer.id;
req.session.customerEmail = emailToVerify;
req.session.customerName = customer.first_name;
delete req.session.pendingVerificationEmail;
logger.info(`Email verified for customer: ${emailToVerify}`);
res.json({
success: true,
message: "Email verified successfully! You are now logged in.",
customer: {
firstName: customer.first_name,
lastName: customer.last_name,
email: emailToVerify,
},
});
} catch (error) {
logger.error("Email verification error:", error);
res.status(500).json({
success: false,
message: "Verification failed. Please try again.",
});
}
});
// ===========================
// RESEND VERIFICATION CODE
// ===========================
router.post("/resend-code", resendCodeLimiter, async (req, res) => {
try {
const { email } = req.body;
const emailToVerify =
email?.toLowerCase() || req.session.pendingVerificationEmail;
if (!emailToVerify) {
return res.status(400).json({
success: false,
message: "Email is required",
});
}
const result = await pool.query(
"SELECT id, first_name, email_verified FROM customers WHERE email = $1",
[emailToVerify],
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: "Account not found. Please sign up first.",
});
}
const customer = result.rows[0];
if (customer.email_verified) {
return res.json({
success: true,
message: "Email already verified. You can now login.",
alreadyVerified: true,
});
}
// Generate new verification code
const verificationCode = generateVerificationCode();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await pool.query(
`UPDATE customers
SET verification_code = $1, verification_code_expires = $2
WHERE id = $3`,
[verificationCode, expiresAt, customer.id],
);
// Send verification email
await sendVerificationEmail(
emailToVerify,
verificationCode,
customer.first_name,
);
res.json({
success: true,
message: "New verification code sent to your email.",
});
} catch (error) {
logger.error("Resend code error:", error);
res.status(500).json({
success: false,
message: "Failed to resend code. Please try again.",
});
}
});
// ===========================
// LOGIN
// ===========================
router.post("/login", authRateLimiter, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
message: "Email and password are required",
});
}
// SECURITY: Sanitize and validate email format
const sanitizedEmail = email.toLowerCase().trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(sanitizedEmail)) {
// SECURITY: Use consistent timing to prevent enumeration
await bcrypt.hash("dummy-password", 12);
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email, password_hash, email_verified, is_active
FROM customers WHERE email = $1`,
[sanitizedEmail],
);
if (result.rows.length === 0) {
// SECURITY: Perform dummy hash to prevent timing attacks
await bcrypt.hash("dummy-password", 12);
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
const customer = result.rows[0];
if (!customer.is_active) {
return res.status(403).json({
success: false,
message: "This account has been deactivated. Please contact support.",
});
}
if (!customer.email_verified) {
// Store email for verification flow
req.session.pendingVerificationEmail = email.toLowerCase();
return res.status(403).json({
success: false,
message: "Please verify your email before logging in.",
requiresVerification: true,
});
}
// Verify password
const isValidPassword = await bcrypt.compare(
password,
customer.password_hash,
);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: "Invalid email or password",
});
}
// Update last login
await pool.query(
`UPDATE customers SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = $1`,
[customer.id],
);
// Set session
req.session.customerId = customer.id;
req.session.customerEmail = customer.email;
req.session.customerName = customer.first_name;
logger.info(`Customer login: ${email}`);
res.json({
success: true,
message: "Login successful",
customer: {
id: customer.id,
firstName: customer.first_name,
lastName: customer.last_name,
email: customer.email,
},
});
} catch (error) {
logger.error("Login error:", error);
res.status(500).json({
success: false,
message: "Login failed. Please try again.",
});
}
});
// ===========================
// LOGOUT
// ===========================
router.post("/logout", (req, res) => {
req.session.customerId = null;
req.session.customerEmail = null;
req.session.customerName = null;
res.json({ success: true, message: "Logged out successfully" });
});
// ===========================
// GET CURRENT SESSION
// ===========================
router.get("/session", async (req, res) => {
try {
if (!req.session.customerId) {
return res.json({
success: true,
loggedIn: false,
});
}
const result = await pool.query(
`SELECT id, first_name, last_name, email, newsletter_subscribed, created_at
FROM customers WHERE id = $1`,
[req.session.customerId],
);
if (result.rows.length === 0) {
req.session.customerId = null;
return res.json({
success: true,
loggedIn: false,
});
}
const customer = result.rows[0];
res.json({
success: true,
loggedIn: true,
customer: {
id: customer.id,
firstName: customer.first_name,
lastName: customer.last_name,
email: customer.email,
newsletterSubscribed: customer.newsletter_subscribed,
memberSince: customer.created_at,
},
});
} catch (error) {
logger.error("Session error:", error);
res.status(500).json({ success: false, message: "Failed to get session" });
}
});
// ===========================
// UPDATE PROFILE
// ===========================
router.put("/profile", requireCustomerAuth, async (req, res) => {
try {
const { firstName, lastName, newsletterSubscribed } = req.body;
const updates = [];
const values = [];
let paramIndex = 1;
if (firstName !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(firstName);
}
if (lastName !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(lastName);
}
if (newsletterSubscribed !== undefined) {
updates.push(`newsletter_subscribed = $${paramIndex++}`);
values.push(newsletterSubscribed);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "No fields to update",
});
}
values.push(req.session.customerId);
await pool.query(
`UPDATE customers SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
values,
);
res.json({ success: true, message: "Profile updated successfully" });
} catch (error) {
logger.error("Profile update error:", error);
res
.status(500)
.json({ success: false, message: "Failed to update profile" });
}
});
module.exports = router;
module.exports.requireCustomerAuth = requireCustomerAuth;

View File

@@ -0,0 +1,377 @@
const express = require("express");
const router = express.Router();
const { pool } = require("../config/database");
const logger = require("../config/logger");
// Middleware to check customer auth from session
const requireCustomerAuth = (req, res, next) => {
if (!req.session || !req.session.customerId) {
return res
.status(401)
.json({ success: false, message: "Please login to continue" });
}
next();
};
// ===========================
// CART ROUTES
// ===========================
// Get cart items
router.get("/cart", requireCustomerAuth, async (req, res) => {
try {
const result = await pool.query(
`SELECT cc.id, cc.product_id, cc.quantity, cc.variant_color, cc.variant_size, cc.added_at,
p.name, p.price, p.imageurl, p.slug
FROM customer_cart cc
JOIN products p ON p.id = cc.product_id
WHERE cc.customer_id = $1
ORDER BY cc.added_at DESC`,
[req.session.customerId]
);
const items = result.rows.map((row) => ({
id: row.id,
productId: row.product_id,
name: row.name,
price: parseFloat(row.price),
image: row.imageurl,
slug: row.slug,
quantity: row.quantity,
variantColor: row.variant_color,
variantSize: row.variant_size,
addedAt: row.added_at,
}));
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
res.json({
success: true,
items,
itemCount: items.length,
total: total.toFixed(2),
});
} catch (error) {
logger.error("Get cart error:", error);
res.status(500).json({ success: false, message: "Failed to get cart" });
}
});
// Add to cart
router.post("/cart", requireCustomerAuth, async (req, res) => {
try {
const { productId, quantity = 1, variantColor, variantSize } = req.body;
if (!productId) {
return res
.status(400)
.json({ success: false, message: "Product ID is required" });
}
// Check if product exists
const productCheck = await pool.query(
"SELECT id, name FROM products WHERE id = $1",
[productId]
);
if (productCheck.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Product not found" });
}
// Insert or update cart item
const result = await pool.query(
`INSERT INTO customer_cart (customer_id, product_id, quantity, variant_color, variant_size)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (customer_id, product_id, variant_color, variant_size)
DO UPDATE SET quantity = customer_cart.quantity + EXCLUDED.quantity, updated_at = CURRENT_TIMESTAMP
RETURNING id`,
[
req.session.customerId,
productId,
quantity,
variantColor || null,
variantSize || null,
]
);
logger.info(
`Cart item added for customer ${req.session.customerId}: ${productId}`
);
res.json({
success: true,
message: "Added to cart",
cartItemId: result.rows[0].id,
});
} catch (error) {
logger.error("Add to cart error:", error);
res.status(500).json({ success: false, message: "Failed to add to cart" });
}
});
// Update cart quantity
router.put("/cart/:id", requireCustomerAuth, async (req, res) => {
try {
const { quantity } = req.body;
if (!quantity || quantity < 1) {
return res
.status(400)
.json({ success: false, message: "Quantity must be at least 1" });
}
const result = await pool.query(
`UPDATE customer_cart SET quantity = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND customer_id = $3
RETURNING id`,
[quantity, req.params.id, req.session.customerId]
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Cart item not found" });
}
res.json({ success: true, message: "Cart updated" });
} catch (error) {
logger.error("Update cart error:", error);
res.status(500).json({ success: false, message: "Failed to update cart" });
}
});
// Remove from cart
router.delete("/cart/:id", requireCustomerAuth, async (req, res) => {
try {
const result = await pool.query(
"DELETE FROM customer_cart WHERE id = $1 AND customer_id = $2 RETURNING id",
[req.params.id, req.session.customerId]
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Cart item not found" });
}
res.json({ success: true, message: "Removed from cart" });
} catch (error) {
logger.error("Remove from cart error:", error);
res
.status(500)
.json({ success: false, message: "Failed to remove from cart" });
}
});
// Clear cart
router.delete("/cart", requireCustomerAuth, async (req, res) => {
try {
await pool.query("DELETE FROM customer_cart WHERE customer_id = $1", [
req.session.customerId,
]);
res.json({ success: true, message: "Cart cleared" });
} catch (error) {
logger.error("Clear cart error:", error);
res.status(500).json({ success: false, message: "Failed to clear cart" });
}
});
// ===========================
// WISHLIST ROUTES
// ===========================
// Get wishlist items
router.get("/wishlist", requireCustomerAuth, async (req, res) => {
try {
const result = await pool.query(
`SELECT cw.id, cw.product_id, cw.added_at,
p.name, p.price, p.imageurl, p.slug
FROM customer_wishlist cw
JOIN products p ON p.id = cw.product_id
WHERE cw.customer_id = $1
ORDER BY cw.added_at DESC`,
[req.session.customerId]
);
const items = result.rows.map((row) => ({
id: row.id,
productId: row.product_id,
name: row.name,
price: parseFloat(row.price),
image: row.imageurl,
slug: row.slug,
addedAt: row.added_at,
}));
res.json({
success: true,
items,
itemCount: items.length,
});
} catch (error) {
logger.error("Get wishlist error:", error);
res.status(500).json({ success: false, message: "Failed to get wishlist" });
}
});
// Add to wishlist
router.post("/wishlist", requireCustomerAuth, async (req, res) => {
try {
const { productId } = req.body;
if (!productId) {
return res
.status(400)
.json({ success: false, message: "Product ID is required" });
}
// Check if product exists
const productCheck = await pool.query(
"SELECT id, name FROM products WHERE id = $1",
[productId]
);
if (productCheck.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Product not found" });
}
// Insert wishlist item (ignore if already exists)
await pool.query(
`INSERT INTO customer_wishlist (customer_id, product_id)
VALUES ($1, $2)
ON CONFLICT (customer_id, product_id) DO NOTHING`,
[req.session.customerId, productId]
);
logger.info(
`Wishlist item added for customer ${req.session.customerId}: ${productId}`
);
res.json({ success: true, message: "Added to wishlist" });
} catch (error) {
logger.error("Add to wishlist error:", error);
res
.status(500)
.json({ success: false, message: "Failed to add to wishlist" });
}
});
// Remove from wishlist
router.delete("/wishlist/:id", requireCustomerAuth, async (req, res) => {
try {
const result = await pool.query(
"DELETE FROM customer_wishlist WHERE id = $1 AND customer_id = $2 RETURNING id",
[req.params.id, req.session.customerId]
);
if (result.rows.length === 0) {
return res
.status(404)
.json({ success: false, message: "Wishlist item not found" });
}
res.json({ success: true, message: "Removed from wishlist" });
} catch (error) {
logger.error("Remove from wishlist error:", error);
res
.status(500)
.json({ success: false, message: "Failed to remove from wishlist" });
}
});
// Remove from wishlist by product ID
router.delete(
"/wishlist/product/:productId",
requireCustomerAuth,
async (req, res) => {
try {
await pool.query(
"DELETE FROM customer_wishlist WHERE product_id = $1 AND customer_id = $2",
[req.params.productId, req.session.customerId]
);
res.json({ success: true, message: "Removed from wishlist" });
} catch (error) {
logger.error("Remove from wishlist error:", error);
res
.status(500)
.json({ success: false, message: "Failed to remove from wishlist" });
}
}
);
// Check if product is in wishlist
router.get(
"/wishlist/check/:productId",
requireCustomerAuth,
async (req, res) => {
try {
const result = await pool.query(
"SELECT id FROM customer_wishlist WHERE product_id = $1 AND customer_id = $2",
[req.params.productId, req.session.customerId]
);
res.json({
success: true,
inWishlist: result.rows.length > 0,
wishlistItemId: result.rows[0]?.id || null,
});
} catch (error) {
logger.error("Check wishlist error:", error);
res
.status(500)
.json({ success: false, message: "Failed to check wishlist" });
}
}
);
// Get cart count (for navbar badge)
router.get("/cart/count", async (req, res) => {
try {
if (!req.session || !req.session.customerId) {
return res.json({ success: true, count: 0 });
}
const result = await pool.query(
"SELECT COALESCE(SUM(quantity), 0) as count FROM customer_cart WHERE customer_id = $1",
[req.session.customerId]
);
res.json({
success: true,
count: parseInt(result.rows[0].count),
});
} catch (error) {
logger.error("Get cart count error:", error);
res.json({ success: true, count: 0 });
}
});
// Get wishlist count (for navbar badge)
router.get("/wishlist/count", async (req, res) => {
try {
if (!req.session || !req.session.customerId) {
return res.json({ success: true, count: 0 });
}
const result = await pool.query(
"SELECT COUNT(*) as count FROM customer_wishlist WHERE customer_id = $1",
[req.session.customerId]
);
res.json({
success: true,
count: parseInt(result.rows[0].count),
});
} catch (error) {
logger.error("Get wishlist count error:", error);
res.json({ success: true, count: 0 });
}
});
module.exports = router;

View File

@@ -16,6 +16,14 @@ const {
sendError,
sendNotFound,
} = require("../utils/responseHelpers");
const {
buildProductQuery,
buildSingleProductQuery,
buildBlogQuery,
buildPagesQuery,
buildPortfolioQuery,
buildCategoriesQuery,
} = require("../utils/queryBuilders");
const router = express.Router();
// Apply global optimizations to all routes
@@ -23,52 +31,15 @@ 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, optimized with index hints
router.get(
"/products",
cacheMiddleware(300000),
asyncHandler(async (req, res) => {
const result = await query(
`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
LIMIT 100` // Prevent full table scan
);
const queryText = buildProductQuery({ limit: 100 });
const result = await query(queryText);
sendSuccess(res, { products: result.rows });
})
}),
);
// Get featured products - Cached for 10 minutes, optimized with index scan
@@ -77,19 +48,13 @@ router.get(
cacheMiddleware(600000, (req) => `featured:${req.query.limit || 4}`),
asyncHandler(async (req, res) => {
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, ${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
LIMIT $1`,
[limit]
);
const queryText = buildProductQuery({
where: "p.isactive = true AND p.isfeatured = true",
limit,
});
const result = await query(queryText);
sendSuccess(res, { products: result.rows });
})
}),
);
// Get single product by ID or slug - Cached for 15 minutes
@@ -97,61 +62,25 @@ router.get(
"/products/:identifier",
cacheMiddleware(900000, (req) => `product:${req.params.identifier}`),
asyncHandler(async (req, res) => {
const { identifier } = req.params;
// Optimized UUID check
const isUUID = identifier.length === 36 && identifier.indexOf("-") === 8;
// 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,
'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),
'[]'::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]
);
const { text, values } = buildSingleProductQuery(req.params.identifier);
const result = await query(text, values);
if (result.rows.length === 0) {
return sendNotFound(res, "Product");
}
sendSuccess(res, { product: result.rows[0] });
})
}),
);
// Get all product categories - Cached for 30 minutes
router.get(
"/categories",
cacheMiddleware(1800000), // 30 minutes cache
cacheMiddleware(1800000),
asyncHandler(async (req, res) => {
const result = await query(
`SELECT DISTINCT category
FROM products
WHERE isactive = true AND category IS NOT NULL AND category != ''
ORDER BY category ASC`
);
const result = await query(buildCategoriesQuery());
sendSuccess(res, { categories: result.rows.map((row) => row.category) });
})
}),
);
// Get site settings
@@ -160,46 +89,39 @@ router.get(
asyncHandler(async (req, res) => {
const result = await query("SELECT * FROM sitesettings LIMIT 1");
sendSuccess(res, { settings: result.rows[0] || {} });
})
}),
);
// Get homepage sections - Cached for 15 minutes
router.get(
"/homepage/sections",
cacheMiddleware(900000), // 15 minutes cache
cacheMiddleware(900000),
asyncHandler(async (req, res) => {
const result = await query(
"SELECT * FROM homepagesections ORDER BY displayorder ASC"
"SELECT * FROM homepagesections ORDER BY displayorder ASC",
);
sendSuccess(res, { sections: result.rows });
})
}),
);
// Get portfolio projects - Cached for 10 minutes
router.get(
"/portfolio/projects",
cacheMiddleware(600000), // 10 minutes cache
cacheMiddleware(600000),
asyncHandler(async (req, res) => {
const result = await query(
`SELECT id, title, description, featuredimage, images, category,
categoryid, isactive, createdat
FROM portfolioprojects WHERE isactive = true
ORDER BY displayorder ASC, createdat DESC`
);
const result = await query(buildPortfolioQuery());
sendSuccess(res, { projects: result.rows });
})
}),
);
// Get blog posts - Cached for 5 minutes
router.get(
"/blog/posts",
cacheMiddleware(300000), // 5 minutes cache
cacheMiddleware(300000),
asyncHandler(async (req, res) => {
const result = await query(
`SELECT id, title, slug, excerpt, content, imageurl, ispublished, createdat
FROM blogposts WHERE ispublished = true ORDER BY createdat DESC`
);
const result = await query(buildBlogQuery());
sendSuccess(res, { posts: result.rows });
})
}),
);
// Get single blog post by slug
@@ -208,7 +130,7 @@ router.get(
asyncHandler(async (req, res) => {
const result = await query(
"SELECT * FROM blogposts WHERE slug = $1 AND ispublished = true",
[req.params.slug]
[req.params.slug],
);
if (result.rows.length === 0) {
@@ -216,7 +138,7 @@ router.get(
}
sendSuccess(res, { post: result.rows[0] });
})
}),
);
// Get custom pages - Cached for 10 minutes
@@ -224,35 +146,48 @@ 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`
);
const result = await query(buildPagesQuery());
sendSuccess(res, { pages: result.rows });
})
}),
);
// Get single page by slug - Cached for 15 minutes
// Get single page by slug - Cache disabled for immediate updates
router.get(
"/pages/:slug",
cacheMiddleware(900000, (req) => `page:${req.params.slug}`),
asyncHandler(async (req, res) => {
console.log("=== PUBLIC PAGE REQUEST ===");
console.log("Requested slug:", req.params.slug);
// Add no-cache headers
res.set({
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
});
const result = await query(
`SELECT id, title, slug, pagecontent as content, metatitle, metadescription
`SELECT id, title, slug, pagecontent as content, metatitle, metadescription, pagedata
FROM pages
WHERE slug = $1 AND isactive = true`,
[req.params.slug]
[req.params.slug],
);
if (result.rows.length === 0) {
console.log("Page not found for slug:", req.params.slug);
return sendNotFound(res, "Page");
}
console.log("=== RETURNING PAGE DATA ===");
console.log("Page found, ID:", result.rows[0].id);
console.log(
"PageData:",
result.rows[0].pagedata
? JSON.stringify(result.rows[0].pagedata).substring(0, 200) + "..."
: "null",
);
sendSuccess(res, { page: result.rows[0] });
})
}),
);
// Get menu items for frontend navigation - Cached for 30 minutes
@@ -261,13 +196,13 @@ router.get(
cacheMiddleware(1800000),
asyncHandler(async (req, res) => {
const result = await query(
"SELECT settings FROM site_settings WHERE key = 'menu'"
"SELECT settings FROM site_settings WHERE key = 'menu'",
);
const items =
result.rows.length > 0 ? result.rows[0].settings.items || [] : [];
const visibleItems = items.filter((item) => item.visible !== false);
sendSuccess(res, { items: visibleItems });
})
}),
);
// Get homepage settings for frontend
@@ -275,11 +210,11 @@ router.get(
"/homepage/settings",
asyncHandler(async (req, res) => {
const result = await query(
"SELECT settings FROM site_settings WHERE key = 'homepage'"
"SELECT settings FROM site_settings WHERE key = 'homepage'",
);
const settings = result.rows.length > 0 ? result.rows[0].settings : {};
sendSuccess(res, { settings });
})
}),
);
// Get all team members (public)
@@ -287,10 +222,10 @@ router.get(
"/team-members",
asyncHandler(async (req, res) => {
const result = await query(
"SELECT id, name, position, bio, image_url FROM team_members ORDER BY display_order ASC, created_at DESC"
"SELECT id, name, position, bio, image_url FROM team_members ORDER BY display_order ASC, created_at DESC",
);
sendSuccess(res, { teamMembers: result.rows });
})
}),
);
// Get menu items (public)
@@ -298,7 +233,7 @@ router.get(
"/menu",
asyncHandler(async (req, res) => {
const result = await query(
"SELECT settings FROM site_settings WHERE key = 'menu'"
"SELECT settings FROM site_settings WHERE key = 'menu'",
);
if (result.rows.length === 0) {
@@ -320,7 +255,7 @@ router.get(
// Filter only visible items for public
const visibleItems = items.filter((item) => item.visible !== false);
sendSuccess(res, { items: visibleItems });
})
}),
);
module.exports = router;

View File

@@ -15,14 +15,20 @@ const MAGIC_BYTES = {
png: [0x89, 0x50, 0x4e, 0x47],
gif: [0x47, 0x49, 0x46],
webp: [0x52, 0x49, 0x46, 0x46],
bmp: [0x42, 0x4d],
tiff_le: [0x49, 0x49, 0x2a, 0x00],
tiff_be: [0x4d, 0x4d, 0x00, 0x2a],
ico: [0x00, 0x00, 0x01, 0x00],
avif: [0x00, 0x00, 0x00], // AVIF starts with ftyp box
heic: [0x00, 0x00, 0x00], // HEIC starts with ftyp box
};
// Validate file content by checking magic bytes
const validateFileContent = async (filePath, mimetype) => {
try {
const buffer = Buffer.alloc(8);
const buffer = Buffer.alloc(12);
const fd = await fs.open(filePath, "r");
await fd.read(buffer, 0, 8, 0);
await fd.read(buffer, 0, 12, 0);
await fd.close();
// Check JPEG
@@ -51,18 +57,73 @@ const validateFileContent = async (filePath, mimetype) => {
buffer[3] === 0x46
);
}
return false;
// Check BMP
if (mimetype === "image/bmp") {
return buffer[0] === 0x42 && buffer[1] === 0x4d;
}
// Check TIFF (both little-endian and big-endian)
if (mimetype === "image/tiff") {
return (
(buffer[0] === 0x49 &&
buffer[1] === 0x49 &&
buffer[2] === 0x2a &&
buffer[3] === 0x00) ||
(buffer[0] === 0x4d &&
buffer[1] === 0x4d &&
buffer[2] === 0x00 &&
buffer[3] === 0x2a)
);
}
// Check ICO
if (
mimetype === "image/x-icon" ||
mimetype === "image/vnd.microsoft.icon" ||
mimetype === "image/ico"
) {
return (
buffer[0] === 0x00 &&
buffer[1] === 0x00 &&
buffer[2] === 0x01 &&
buffer[3] === 0x00
);
}
// Check SVG (text-based, starts with < or whitespace then <)
if (mimetype === "image/svg+xml") {
const text = buffer.toString("utf8").trim();
return text.startsWith("<") || text.startsWith("<?xml");
}
// Check AVIF/HEIC/HEIF (ftyp box based formats - more relaxed check)
if (
mimetype === "image/avif" ||
mimetype === "image/heic" ||
mimetype === "image/heif"
) {
// These formats have "ftyp" at offset 4
return (
buffer[4] === 0x66 &&
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70
);
}
// Check video files (MP4, WebM, MOV, AVI, MKV - allow based on MIME type)
if (mimetype.startsWith("video/")) {
return true; // Trust MIME type for video files
}
// For unknown types, allow them through (rely on MIME type check)
return true;
} catch (error) {
logger.error("Magic byte validation error:", error);
return false;
}
};
// Allowed file types
// Allowed file types - extended to support more image formats and video
const ALLOWED_MIME_TYPES = (
process.env.ALLOWED_FILE_TYPES || "image/jpeg,image/png,image/gif,image/webp"
process.env.ALLOWED_FILE_TYPES ||
"image/jpeg,image/jpg,image/png,image/gif,image/webp,image/bmp,image/tiff,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,image/ico,image/avif,image/heic,image/heif,video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska"
).split(",");
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024; // 5MB default
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 100 * 1024 * 1024; // 100MB default for video support
// Configure multer for file uploads
const storage = multer.diskStorage({
@@ -105,16 +166,35 @@ const upload = multer({
return cb(
new Error(
`File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(
", "
)}`
", ",
)}`,
),
false
false,
);
}
// Validate file extension
const ext = path.extname(file.originalname).toLowerCase();
const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
const allowedExtensions = [
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
".svg",
".ico",
".avif",
".heic",
".heif",
".mp4",
".webm",
".mov",
".avi",
".mkv",
];
if (!allowedExtensions.includes(ext)) {
logger.warn("File upload rejected - invalid extension", {
extension: ext,
@@ -159,7 +239,7 @@ router.post(
await fs
.unlink(file.path)
.catch((err) =>
logger.error("Failed to clean up invalid file:", err)
logger.error("Failed to clean up invalid file:", err),
);
return res.status(400).json({
success: false,
@@ -184,7 +264,7 @@ router.post(
file.mimetype,
uploadedBy,
folderId,
]
],
);
files.push({
@@ -242,7 +322,7 @@ router.post(
}
next(error);
}
}
},
);
// Get all uploaded files
@@ -250,35 +330,40 @@ router.get("/uploads", requireAuth, async (req, res) => {
try {
const folderId = req.query.folder_id;
let query = `SELECT
id,
filename,
original_name,
file_path,
file_size,
mime_type,
uploaded_by,
folder_id,
created_at,
updated_at,
used_in_type,
used_in_id
FROM uploads`;
// SECURITY: Use parameterized queries for all conditions
let queryText;
const params = [];
if (folderId !== undefined) {
if (folderId === "null" || folderId === "") {
query += ` WHERE folder_id IS NULL`;
} else {
query += ` WHERE folder_id = $1`;
params.push(parseInt(folderId));
if (folderId === undefined) {
queryText = `SELECT
id, filename, original_name, file_path, file_size,
mime_type, uploaded_by, folder_id, created_at,
updated_at, used_in_type, used_in_id
FROM uploads ORDER BY created_at DESC`;
} else if (folderId === "null" || folderId === "") {
queryText = `SELECT
id, filename, original_name, file_path, file_size,
mime_type, uploaded_by, folder_id, created_at,
updated_at, used_in_type, used_in_id
FROM uploads WHERE folder_id IS NULL ORDER BY created_at DESC`;
} else {
// SECURITY: Validate folder_id is a valid integer
const parsedFolderId = parseInt(folderId, 10);
if (isNaN(parsedFolderId) || parsedFolderId < 0) {
return res.status(400).json({
success: false,
error: "Invalid folder ID",
});
}
queryText = `SELECT
id, filename, original_name, file_path, file_size,
mime_type, uploaded_by, folder_id, created_at,
updated_at, used_in_type, used_in_id
FROM uploads WHERE folder_id = $1 ORDER BY created_at DESC`;
params.push(parsedFolderId);
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
const result = await pool.query(queryText, params);
const files = result.rows.map((row) => ({
id: row.id,
@@ -312,10 +397,30 @@ router.delete("/uploads/:filename", requireAuth, async (req, res) => {
try {
const filename = req.params.filename;
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
const filePath = path.join(uploadDir, filename);
// Security check: ensure file is within uploads directory
if (!filePath.startsWith(uploadDir)) {
// SECURITY: Sanitize filename - remove any path traversal attempts
const sanitizedFilename = path
.basename(filename)
.replace(/[^a-zA-Z0-9._-]/g, "");
if (!sanitizedFilename || sanitizedFilename !== filename) {
logger.warn("Path traversal attempt detected", { filename, ip: req.ip });
return res.status(403).json({
success: false,
error: "Invalid filename",
});
}
const filePath = path.join(uploadDir, sanitizedFilename);
const resolvedPath = path.resolve(filePath);
const resolvedUploadDir = path.resolve(uploadDir);
// SECURITY: Double-check path is within uploads directory after resolution
if (!resolvedPath.startsWith(resolvedUploadDir + path.sep)) {
logger.warn("Path traversal attempt blocked", {
filename,
resolvedPath,
ip: req.ip,
});
return res.status(403).json({
success: false,
error: "Invalid file path",
@@ -325,7 +430,7 @@ router.delete("/uploads/:filename", requireAuth, async (req, res) => {
// Start transaction: delete from database first
const result = await pool.query(
"DELETE FROM uploads WHERE filename = $1 RETURNING id",
[filename]
[filename],
);
if (result.rowCount === 0) {
@@ -364,7 +469,7 @@ router.delete("/uploads/id/:id", requireAuth, async (req, res) => {
// Get file info first
const fileResult = await pool.query(
"SELECT filename FROM uploads WHERE id = $1",
[fileId]
[fileId],
);
if (fileResult.rows.length === 0) {
@@ -423,7 +528,7 @@ router.post("/folders", requireAuth, async (req, res) => {
if (parent_id) {
const parentResult = await pool.query(
"SELECT path FROM media_folders WHERE id = $1",
[parent_id]
[parent_id],
);
if (parentResult.rows.length === 0) {
@@ -442,7 +547,7 @@ router.post("/folders", requireAuth, async (req, res) => {
`INSERT INTO media_folders (name, parent_id, path, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, name, parent_id, path, created_at`,
[sanitizedName, parent_id || null, path, createdBy]
[sanitizedName, parent_id || null, path, createdBy],
);
res.json({
@@ -484,7 +589,7 @@ router.get("/folders", requireAuth, async (req, res) => {
(SELECT COUNT(*) FROM uploads WHERE folder_id = f.id) as file_count,
(SELECT COUNT(*) FROM media_folders WHERE parent_id = f.id) as subfolder_count
FROM media_folders f
ORDER BY f.path ASC`
ORDER BY f.path ASC`,
);
const folders = result.rows.map((row) => ({
@@ -519,7 +624,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
// Check if folder exists
const folderResult = await pool.query(
"SELECT id, name FROM media_folders WHERE id = $1",
[folderId]
[folderId],
);
if (folderResult.rows.length === 0) {
@@ -538,7 +643,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
SELECT path || '%' FROM media_folders WHERE id = $1
)
)`,
[folderId]
[folderId],
);
// Delete physical files
@@ -559,7 +664,7 @@ router.delete("/folders/:id", requireAuth, async (req, res) => {
`SELECT
(SELECT COUNT(*) FROM uploads WHERE folder_id = $1) as file_count,
(SELECT COUNT(*) FROM media_folders WHERE parent_id = $1) as subfolder_count`,
[folderId]
[folderId],
);
const fileCount = parseInt(contentsCheck.rows[0].file_count);
@@ -606,7 +711,7 @@ router.patch("/uploads/move", requireAuth, async (req, res) => {
if (targetFolderId) {
const folderCheck = await pool.query(
"SELECT id FROM media_folders WHERE id = $1",
[targetFolderId]
[targetFolderId],
);
if (folderCheck.rows.length === 0) {
@@ -623,7 +728,7 @@ router.patch("/uploads/move", requireAuth, async (req, res) => {
SET folder_id = $1, updated_at = NOW()
WHERE id = ANY($2::int[])
RETURNING id`,
[targetFolderId, file_ids]
[targetFolderId, file_ids],
);
res.json({
@@ -655,13 +760,13 @@ router.post("/uploads/bulk-delete", requireAuth, async (req, res) => {
// Get filenames first
const filesResult = await pool.query(
"SELECT filename FROM uploads WHERE id = ANY($1::int[])",
[file_ids]
[file_ids],
);
// Delete from database
const result = await pool.query(
"DELETE FROM uploads WHERE id = ANY($1::int[])",
[file_ids]
[file_ids],
);
// Delete physical files
@@ -688,4 +793,165 @@ router.post("/uploads/bulk-delete", requireAuth, async (req, res) => {
}
});
// Rename a file
router.patch("/uploads/:id/rename", requireAuth, async (req, res) => {
try {
const fileId = parseInt(req.params.id);
const { newName } = req.body;
if (!newName || newName.trim() === "") {
return res.status(400).json({
success: false,
error: "New name is required",
});
}
// Get current file info
const fileResult = await pool.query(
"SELECT filename, original_name FROM uploads WHERE id = $1",
[fileId],
);
if (fileResult.rows.length === 0) {
return res.status(404).json({
success: false,
error: "File not found",
});
}
const currentFile = fileResult.rows[0];
const ext = path.extname(currentFile.filename);
// Sanitize new name and keep extension
const sanitizedName = newName
.trim()
.replace(/[^a-z0-9\s\-_]/gi, "-")
.toLowerCase()
.substring(0, 100);
const newFilename = sanitizedName + "-" + Date.now() + ext;
const uploadDir = path.join(__dirname, "..", "..", "website", "uploads");
const oldPath = path.join(uploadDir, currentFile.filename);
const newPath = path.join(uploadDir, newFilename);
// Rename physical file
try {
await fs.rename(oldPath, newPath);
} catch (fileError) {
logger.error("Error renaming physical file:", fileError);
return res.status(500).json({
success: false,
error: "Failed to rename file on disk",
});
}
// Update database
const result = await pool.query(
`UPDATE uploads
SET filename = $1, original_name = $2, file_path = $3, updated_at = NOW()
WHERE id = $4
RETURNING id, filename, original_name, file_path`,
[newFilename, newName.trim() + ext, `/uploads/${newFilename}`, fileId],
);
res.json({
success: true,
message: "File renamed successfully",
file: {
id: result.rows[0].id,
filename: result.rows[0].filename,
originalName: result.rows[0].original_name,
path: result.rows[0].file_path,
},
});
} catch (error) {
logger.error("Error renaming file:", error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// Rename a folder
router.patch("/folders/:id/rename", requireAuth, async (req, res) => {
try {
const folderId = parseInt(req.params.id);
const { newName } = req.body;
if (!newName || newName.trim() === "") {
return res.status(400).json({
success: false,
error: "New name is required",
});
}
// Get current folder info
const folderResult = await pool.query(
"SELECT id, name, parent_id, path FROM media_folders WHERE id = $1",
[folderId],
);
if (folderResult.rows.length === 0) {
return res.status(404).json({
success: false,
error: "Folder not found",
});
}
const currentFolder = folderResult.rows[0];
const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9\s\-_]/g, "");
// Build new path
const oldPath = currentFolder.path;
const pathParts = oldPath.split("/");
pathParts[pathParts.length - 1] = sanitizedName;
const newPath = pathParts.join("/");
// Check for duplicate name in same parent
const duplicateCheck = await pool.query(
`SELECT id FROM media_folders
WHERE name = $1 AND parent_id IS NOT DISTINCT FROM $2 AND id != $3`,
[sanitizedName, currentFolder.parent_id, folderId],
);
if (duplicateCheck.rows.length > 0) {
return res.status(400).json({
success: false,
error: "A folder with this name already exists in this location",
});
}
// Update folder and all subfolders paths
await pool.query(
`UPDATE media_folders SET name = $1, path = $2, updated_at = NOW() WHERE id = $3`,
[sanitizedName, newPath, folderId],
);
// Update subfolders paths
await pool.query(
`UPDATE media_folders
SET path = REPLACE(path, $1, $2), updated_at = NOW()
WHERE path LIKE $3 AND id != $4`,
[oldPath, newPath, oldPath + "/%", folderId],
);
res.json({
success: true,
message: "Folder renamed successfully",
folder: {
id: folderId,
name: sanitizedName,
path: newPath,
},
});
} catch (error) {
logger.error("Error renaming folder:", error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
module.exports = router;

View File

@@ -70,7 +70,7 @@ router.get("/:id", async (req, res) => {
FROM adminusers u
WHERE u.id = $1
`,
[id]
[id],
);
if (result.rows.length === 0) {
@@ -107,7 +107,7 @@ router.post("/", async (req, res) => {
// Check if user already exists
const existing = await query(
"SELECT id FROM adminusers WHERE email = $1 OR username = $2",
[email, username]
[email, username],
);
if (existing.rows.length > 0) {
@@ -117,8 +117,36 @@ router.post("/", async (req, res) => {
});
}
// Hash password with bcrypt (10 rounds)
const hashedPassword = await bcrypt.hash(password, 10);
// Hash password with bcrypt (12 rounds minimum for security)
const BCRYPT_COST = 12;
// Validate password requirements
if (password.length < 8) {
return res.status(400).json({
success: false,
message: "Password must be at least 8 characters long",
});
}
if (!/[A-Z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one uppercase letter",
});
}
if (!/[a-z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one lowercase letter",
});
}
if (!/[0-9]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one number",
});
}
const hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
// Calculate password expiry (90 days from now if not never expires)
let passwordExpiresAt = null;
@@ -128,29 +156,57 @@ router.post("/", async (req, res) => {
passwordExpiresAt = expiryDate.toISOString();
}
// Insert new user with both role and name fields
// Resolve role - handle both role ID (e.g., 'role-admin') and role name (e.g., 'Admin')
let roleId, roleName;
// First try to find by ID
let roleResult = await query("SELECT id, name FROM roles WHERE id = $1", [
role,
]);
if (roleResult.rows.length > 0) {
// Found by ID
roleId = roleResult.rows[0].id;
roleName = roleResult.rows[0].name;
} else {
// Try to find by name
roleResult = await query("SELECT id, name FROM roles WHERE name = $1", [
role,
]);
if (roleResult.rows.length > 0) {
roleId = roleResult.rows[0].id;
roleName = roleResult.rows[0].name;
} else {
// Default to admin role
roleId = "role-admin";
roleName = "Admin";
}
}
// Insert new user with both role and role_id fields
const result = await query(
`
INSERT INTO adminusers (
id, name, username, email, passwordhash, role,
id, name, username, email, passwordhash, role, role_id,
passwordneverexpires, password_expires_at,
isactive, created_by, createdat, lastpasswordchange
) VALUES (
'user-' || gen_random_uuid()::text,
$1, $2, $3, $4, $5, $6, $7, true, $8, NOW(), NOW()
$1, $2, $3, $4, $5, $6, $7, $8, true, $9, NOW(), NOW()
)
RETURNING id, name, username, email, role, isactive, createdat, passwordneverexpires
RETURNING id, name, username, email, role, role_id, isactive, createdat, passwordneverexpires
`,
[
name || username,
username,
email,
hashedPassword,
role,
roleName,
roleId,
passwordneverexpires || false,
passwordExpiresAt,
req.session.user.email,
]
],
);
res.json({
@@ -196,8 +252,36 @@ router.put("/:id", async (req, res) => {
values.push(email);
}
if (role !== undefined) {
// Resolve role - handle both role ID (e.g., 'role-admin') and role name (e.g., 'Admin')
let roleId, roleName;
// First try to find by ID
let roleResult = await query("SELECT id, name FROM roles WHERE id = $1", [
role,
]);
if (roleResult.rows.length > 0) {
roleId = roleResult.rows[0].id;
roleName = roleResult.rows[0].name;
} else {
// Try to find by name
roleResult = await query("SELECT id, name FROM roles WHERE name = $1", [
role,
]);
if (roleResult.rows.length > 0) {
roleId = roleResult.rows[0].id;
roleName = roleResult.rows[0].name;
} else {
// Default to admin role
roleId = "role-admin";
roleName = "Admin";
}
}
updates.push(`role = $${paramCount++}`);
values.push(role);
values.push(roleName);
updates.push(`role_id = $${paramCount++}`);
values.push(roleId);
}
if (isactive !== undefined) {
updates.push(`isactive = $${paramCount++}`);
@@ -215,29 +299,33 @@ router.put("/:id", async (req, res) => {
// Handle password update if provided
if (password !== undefined && password !== "") {
// Validate password strength
if (password.length < 12) {
// Validate password requirements
if (password.length < 8) {
return res.status(400).json({
success: false,
message: "Password must be at least 12 characters long",
message: "Password must be at least 8 characters long",
});
}
if (!/[A-Z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one uppercase letter",
});
}
if (!/[a-z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one lowercase letter",
});
}
if (!/[0-9]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one number",
});
}
// 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);
const hashedPassword = await bcrypt.hash(password, 12);
updates.push(`passwordhash = $${paramCount++}`);
values.push(hashedPassword);
updates.push(`lastpasswordchange = NOW()`);
@@ -251,9 +339,9 @@ router.put("/:id", async (req, res) => {
UPDATE adminusers
SET ${updates.join(", ")}
WHERE id = $${paramCount}
RETURNING id, name, username, email, role, isactive, passwordneverexpires
RETURNING id, name, username, email, role, role_id, isactive, passwordneverexpires
`,
values
values,
);
if (result.rows.length === 0) {
@@ -280,20 +368,39 @@ router.put("/:id/password", async (req, res) => {
const { id } = req.params;
const { password } = req.body;
// Validate password requirements
if (!password || password.length < 8) {
return res.status(400).json({
success: false,
message: "Password must be at least 8 characters long",
});
}
if (!/[A-Z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one uppercase letter",
});
}
if (!/[a-z]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one lowercase letter",
});
}
if (!/[0-9]/.test(password)) {
return res.status(400).json({
success: false,
message: "Password must contain at least one number",
});
}
// Hash new password with bcrypt (10 rounds)
const hashedPassword = await bcrypt.hash(password, 10);
// Hash new password with bcrypt (12 rounds)
const hashedPassword = await bcrypt.hash(password, 12);
// Get user's password expiry setting
const userResult = await query(
"SELECT passwordneverexpires FROM adminusers WHERE id = $1",
[id]
[id],
);
if (userResult.rows.length === 0) {
@@ -321,7 +428,7 @@ router.put("/:id/password", async (req, res) => {
updatedat = NOW()
WHERE id = $3
`,
[hashedPassword, passwordExpiresAt, id]
[hashedPassword, passwordExpiresAt, id],
);
res.json({
@@ -352,8 +459,8 @@ router.post("/:id/reset-password", async (req, res) => {
// Get user's password expiry setting
const userResult = await query(
"SELECT password_never_expires FROM adminusers WHERE id = $1",
[id]
"SELECT passwordneverexpires FROM adminusers WHERE id = $1",
[id],
);
if (userResult.rows.length === 0) {
@@ -365,7 +472,7 @@ router.post("/:id/reset-password", async (req, res) => {
// Calculate new expiry date (90 days from now if not never expires)
let passwordExpiresAt = null;
if (!userResult.rows[0].password_never_expires) {
if (!userResult.rows[0].passwordneverexpires) {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 90);
passwordExpiresAt = expiryDate.toISOString();
@@ -377,11 +484,11 @@ router.post("/:id/reset-password", async (req, res) => {
UPDATE adminusers
SET passwordhash = $1,
password_expires_at = $2,
last_password_change = NOW(),
updated_at = NOW()
lastpasswordchange = NOW(),
updatedat = NOW()
WHERE id = $3
`,
[hashedPassword, passwordExpiresAt, id]
[hashedPassword, passwordExpiresAt, id],
);
res.json({
@@ -409,7 +516,7 @@ router.delete("/:id", async (req, res) => {
const result = await query(
"DELETE FROM adminusers WHERE id = $1 RETURNING id",
[id]
[id],
);
if (result.rows.length === 0) {
@@ -450,7 +557,7 @@ router.post("/:id/toggle-status", async (req, res) => {
WHERE id = $1
RETURNING id, isactive
`,
[id]
[id],
);
if (result.rows.length === 0) {