webupdate
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
662
backend/routes/customer-auth.js
Normal file
662
backend/routes/customer-auth.js
Normal 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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
// 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;
|
||||
377
backend/routes/customer-cart.js
Normal file
377
backend/routes/customer-cart.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user