Compare commits

..

4 Commits

Author SHA1 Message Date
yotakii
9e5919fb6c fix validation error 2026-03-05 11:01:49 +03:00
yotakii
212c86d29d fix uploads path to match nginx /var/www/oldvine/uploads 2026-02-22 10:51:41 +03:00
yotakii
3c3aef5446 edit media 2026-02-18 15:40:31 +03:00
yotakii
2909b675a1 more edits 2026-02-15 16:28:31 +03:00
4 changed files with 317 additions and 388 deletions

View File

@@ -24,8 +24,19 @@ const bookingSchema = new mongoose.Schema({
room: { room: {
type: mongoose.Schema.Types.ObjectId, type: mongoose.Schema.Types.ObjectId,
ref: 'Room', ref: 'Room',
required: true required: false
}, },
roomCategoryId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'RoomCategory',
required: false
},
requestedRoomType: {
type: String,
maxlength: 200
},
checkInDate: { checkInDate: {
type: Date, type: Date,
required: true required: true

View File

@@ -10,6 +10,50 @@ const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const sendEmail = require('../utils/sendEmail'); const sendEmail = require('../utils/sendEmail');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const escapeRegex = (s = '') => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
async function resolveRoomFromCategory({ roomCategoryId, requestedRoomType, checkIn, checkOut, totalGuests }) {
const or = [];
if (roomCategoryId) {
or.push(
{ category: roomCategoryId },
{ categoryId: roomCategoryId },
{ roomCategory: roomCategoryId },
{ roomCategoryId: roomCategoryId },
{ typeId: roomCategoryId }
);
}
if (requestedRoomType) {
const rx = new RegExp(`^${escapeRegex(requestedRoomType)}$`, 'i');
or.push(
{ type: rx },
{ roomType: rx },
{ categoryName: rx },
{ name: rx }
);
}
if (or.length === 0) return null;
const candidates = await Room.find({
isActive: true,
$or: or
}).limit(50);
for (const room of candidates) {
try {
if (room.maxOccupancy != null && room.maxOccupancy < totalGuests) continue;
const ok = await room.isAvailable(checkIn, checkOut);
if (ok) return room;
} catch (e) {
// ignore and try next
}
}
return null;
}
// @route POST /api/bookings/request // @route POST /api/bookings/request
// @desc Create a booking request (no online payment) // @desc Create a booking request (no online payment)
@@ -19,7 +63,21 @@ router.post('/request', [
body('guestInfo.lastName').notEmpty().withMessage('Last name is required'), body('guestInfo.lastName').notEmpty().withMessage('Last name is required'),
body('guestInfo.email').isEmail().withMessage('Valid email is required'), body('guestInfo.email').isEmail().withMessage('Valid email is required'),
body('guestInfo.phone').notEmpty().withMessage('Phone number is required'), body('guestInfo.phone').notEmpty().withMessage('Phone number is required'),
body('roomId').isMongoId().withMessage('Valid room ID is required'),
// accept roomId OR roomCategoryId / requestedRoomType
body('roomId').optional().isMongoId().withMessage('Valid room ID is required'),
body('roomCategoryId').optional().isMongoId().withMessage('Valid room category ID is required'),
body('requestedRoomType').optional().isString().isLength({ max: 200 }),
body('roomCategory').optional().isString().isLength({ max: 200 }),
body().custom((_, { req }) => {
if (req.body.roomId) return true;
if (req.body.roomCategoryId) return true;
if (req.body.requestedRoomType) return true;
if (req.body.roomCategory) return true;
throw new Error('roomId or roomCategoryId/requestedRoomType is required');
}),
body('checkInDate').isISO8601().withMessage('Valid check-in date is required'), body('checkInDate').isISO8601().withMessage('Valid check-in date is required'),
body('checkOutDate').isISO8601().withMessage('Valid check-out date is required'), body('checkOutDate').isISO8601().withMessage('Valid check-out date is required'),
body('numberOfGuests.adults').isInt({ min: 1 }).withMessage('At least 1 adult required'), body('numberOfGuests.adults').isInt({ min: 1 }).withMessage('At least 1 adult required'),
@@ -35,7 +93,17 @@ router.post('/request', [
}); });
} }
const { guestInfo, roomId, checkInDate, checkOutDate, numberOfGuests, specialRequests } = req.body; const {
guestInfo,
roomId,
roomCategoryId,
roomCategory,
requestedRoomType,
checkInDate,
checkOutDate,
numberOfGuests,
specialRequests
} = req.body;
const checkIn = new Date(checkInDate); const checkIn = new Date(checkInDate);
const checkOut = new Date(checkOutDate); const checkOut = new Date(checkOutDate);
@@ -47,25 +115,48 @@ router.post('/request', [
return res.status(400).json({ success: false, message: 'Check-in date cannot be in the past' }); return res.status(400).json({ success: false, message: 'Check-in date cannot be in the past' });
} }
const room = await Room.findById(roomId);
if (!room || !room.isActive) {
return res.status(404).json({ success: false, message: 'Room not found' });
}
const totalGuests = Number(numberOfGuests.adults) + Number(numberOfGuests.children || 0); const totalGuests = Number(numberOfGuests.adults) + Number(numberOfGuests.children || 0);
if (room.maxOccupancy < totalGuests) {
return res.status(400).json({
success: false,
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
});
}
const isAvailable = await room.isAvailable(checkIn, checkOut); // If roomId provided, keep old behavior
if (!isAvailable) { let room = null;
return res.status(400).json({ if (roomId) {
success: false, room = await Room.findById(roomId);
message: 'Room is not available for the selected dates' if (!room || !room.isActive) {
return res.status(404).json({ success: false, message: 'Room not found' });
}
if (room.maxOccupancy < totalGuests) {
return res.status(400).json({
success: false,
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
});
}
const isAvailable = await room.isAvailable(checkIn, checkOut);
if (!isAvailable) {
return res.status(400).json({
success: false,
message: 'Room is not available for the selected dates'
});
}
} else {
// No roomId => try to resolve an actual room from category/type
const typeName = requestedRoomType || roomCategory || null;
room = await resolveRoomFromCategory({
roomCategoryId,
requestedRoomType: typeName,
checkIn,
checkOut,
totalGuests
}); });
// If we found a room, enforce occupancy like before
if (room && room.maxOccupancy != null && room.maxOccupancy < totalGuests) {
return res.status(400).json({
success: false,
message: `Room can accommodate maximum ${room.maxOccupancy} guests`
});
}
} }
// Find or create guest // Find or create guest
@@ -80,14 +171,23 @@ router.post('/request', [
// Pricing // Pricing
const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24)); const numberOfNights = Math.ceil((checkOut - checkIn) / (1000 * 60 * 60 * 24));
const roomRate = room.currentPrice;
// If room exists => use its currentPrice (old behavior)
// If room doesn't exist => set roomRate to 0 (or you can later adjust manually in admin)
const roomRate = room ? Number(room.currentPrice || 0) : 0;
const subtotal = roomRate * numberOfNights; const subtotal = roomRate * numberOfNights;
const taxes = subtotal * 0.12; const taxes = subtotal * 0.12;
const totalAmount = subtotal + taxes; const totalAmount = subtotal + taxes;
const booking = new Booking({ const booking = new Booking({
guest: guest._id, guest: guest._id,
room: roomId, room: room ? room._id : undefined,
// store category/type for category-only requests
roomCategoryId: room ? undefined : (roomCategoryId || undefined),
requestedRoomType: room ? undefined : (requestedRoomType || roomCategory || undefined),
checkInDate: checkIn, checkInDate: checkIn,
checkOutDate: checkOut, checkOutDate: checkOut,
numberOfGuests, numberOfGuests,
@@ -332,7 +432,6 @@ router.get('/:bookingNumber', async (req, res) => {
confirmationCode confirmationCode
}).populate(['guest', 'room']); }).populate(['guest', 'room']);
} else { } else {
// Otherwise require authentication (implement auth middleware check here)
booking = await Booking.findOne({ bookingNumber }) booking = await Booking.findOne({ bookingNumber })
.populate(['guest', 'room']); .populate(['guest', 'room']);
} }
@@ -544,12 +643,9 @@ router.get('/', adminAuth, async (req, res) => {
as: 'room' as: 'room'
} }
}, },
{ { $unwind: '$guest' },
$unwind: '$guest' // ✅ preserve bookings even if room is null
}, { $unwind: { path: '$room', preserveNullAndEmptyArrays: true } }
{
$unwind: '$room'
}
); );
const [bookings, totalCount] = await Promise.all([ const [bookings, totalCount] = await Promise.all([
@@ -596,6 +692,14 @@ router.put('/:id/checkin', adminAuth, async (req, res) => {
}); });
} }
// guard if no room assigned
if (!booking.room) {
return res.status(400).json({
success: false,
message: 'Booking has no room assigned'
});
}
if (booking.status !== 'Confirmed') { if (booking.status !== 'Confirmed') {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -603,12 +707,10 @@ router.put('/:id/checkin', adminAuth, async (req, res) => {
}); });
} }
// Update booking status
booking.status = 'Checked In'; booking.status = 'Checked In';
booking.actualCheckInTime = new Date(); booking.actualCheckInTime = new Date();
await booking.save(); await booking.save();
// Update room status
const room = await Room.findById(booking.room._id); const room = await Room.findById(booking.room._id);
room.status = 'Occupied'; room.status = 'Occupied';
await room.save(); await room.save();
@@ -642,6 +744,14 @@ router.put('/:id/checkout', adminAuth, async (req, res) => {
}); });
} }
// guard if no room assigned
if (!booking.room) {
return res.status(400).json({
success: false,
message: 'Booking has no room assigned'
});
}
if (booking.status !== 'Checked In') { if (booking.status !== 'Checked In') {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -649,12 +759,10 @@ router.put('/:id/checkout', adminAuth, async (req, res) => {
}); });
} }
// Update booking status
booking.status = 'Checked Out'; booking.status = 'Checked Out';
booking.actualCheckOutTime = new Date(); booking.actualCheckOutTime = new Date();
await booking.save(); await booking.save();
// Update room status
const room = await Room.findById(booking.room._id); const room = await Room.findById(booking.room._id);
room.status = 'Available'; room.status = 'Available';
room.cleaningStatus = 'Dirty'; room.cleaningStatus = 'Dirty';
@@ -687,7 +795,6 @@ router.get('/analytics/revenue', adminAuth, async (req, res) => {
const revenueData = await Booking.generateRevenueReport(start, end); const revenueData = await Booking.generateRevenueReport(start, end);
// Calculate total metrics
const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0); const totalRevenue = revenueData.reduce((sum, day) => sum + day.totalRevenue, 0);
const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0); const totalBookings = revenueData.reduce((sum, day) => sum + day.bookingsCount, 0);
const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0; const averageBookingValue = totalBookings > 0 ? totalRevenue / totalBookings : 0;

View File

@@ -2,24 +2,21 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const fsNative = require('fs');
const fs = require('fs').promises; const fs = require('fs').promises;
const Media = require('../models/Media'); const Media = require('../models/Media');
const adminAuth = require('../middleware/adminAuth'); const adminAuth = require('../middleware/adminAuth');
// ======================= const isProd = process.env.NODE_ENV === 'production';
// Config (LOCAL + PROD)
// =======================
const UPLOAD_DIR = process.env.UPLOAD_DIR
? path.resolve(process.env.UPLOAD_DIR)
: path.resolve(__dirname, '..', 'uploads'); // local fallback
const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; // In dev: oldvine_cms/client/public/images
const MEDIA_ROOT = process.env.MEDIA_SUBDIR || 'images'; const devImagesRoot = path.join(__dirname, '../../client/public/images');
// Ensure upload directories exist // On server: nginx serves /images from /var/www/oldvine/images (recommended)
const ensureUploadDir = async (dir) => { const imagesRoot =
process.env.IMAGES_DIR ||
(isProd ? '/var/www/oldvine/images' : devImagesRoot);
const ensureDir = async (dir) => {
try { try {
await fs.access(dir); await fs.access(dir);
} catch { } catch {
@@ -27,26 +24,23 @@ const ensureUploadDir = async (dir) => {
} }
}; };
const sanitizeFolder = (folder) => { const safeFolder = (s) =>
let f = String(folder || 'general').trim(); String(s || 'general')
// allow a-z0-9 _ - and slashes for nested folders .toLowerCase()
f = f.replace(/[^a-z0-9/_-]/gi, ''); .replace(/[^a-z0-9]/g, '-')
// prevent traversal .replace(/-+/g, '-')
while (f.includes('..')) f = f.replace(/\.\./g, ''); .replace(/^-|-$/g, '') || 'general';
f = f.replace(/^\/+|\/+$/g, '');
return f || 'general';
};
// Configure multer storage // Configure multer storage
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: async (req, file, cb) => { destination: async (req, file, cb) => {
try { try {
const folderSafe = sanitizeFolder(req.body.folder); const folder = safeFolder(req.body.folder || 'general');
const uploadPath = path.join(UPLOAD_DIR, MEDIA_ROOT, folderSafe); const uploadPath = path.join(imagesRoot, folder);
await ensureUploadDir(uploadPath); await ensureDir(uploadPath);
cb(null, uploadPath); cb(null, uploadPath);
} catch (e) { } catch (err) {
cb(e); cb(err);
} }
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
@@ -58,13 +52,12 @@ const storage = multer.diskStorage({
.replace(/[^a-z0-9]/g, '-') .replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-')
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '');
cb(null, (basename || 'file') + '-' + uniqueSuffix + ext.toLowerCase()); cb(null, `${basename || 'media'}-${uniqueSuffix}${ext.toLowerCase()}`);
}, },
}); });
// File filter
const fileFilter = (req, file, cb) => { const fileFilter = (req, file, cb) => {
const allowedMimeTypes = [ const allowed = [
'image/jpeg', 'image/jpeg',
'image/jpg', 'image/jpg',
'image/png', 'image/png',
@@ -75,58 +68,37 @@ const fileFilter = (req, file, cb) => {
'video/quicktime', 'video/quicktime',
'application/pdf', 'application/pdf',
]; ];
if (allowed.includes(file.mimetype)) return cb(null, true);
if (allowedMimeTypes.includes(file.mimetype)) { cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false);
cb(null, true);
} else {
cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false);
}
}; };
const upload = multer({ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { limits: { fileSize: 10 * 1024 * 1024 },
fileSize: 10 * 1024 * 1024, // 10MB limit
},
}); });
// @route POST /api/media/upload // POST /api/media/upload
// @desc Upload media file
// @access Private (Admin)
router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
try { try {
if (!req.file) { if (!req.file) return res.status(400).json({ success: false, message: 'No file uploaded' });
return res.status(400).json({
success: false,
message: 'No file uploaded',
});
}
const folderSafe = sanitizeFolder(req.body.folder); const folder = safeFolder(req.body.folder || 'general');
const { alt, caption, description, tags } = req.body; const { alt, caption, description, tags } = req.body;
// Determine media type
let mediaType = 'other'; let mediaType = 'other';
if (req.file.mimetype.startsWith('image/')) { if (req.file.mimetype.startsWith('image/')) mediaType = 'image';
mediaType = 'image'; else if (req.file.mimetype.startsWith('video/')) mediaType = 'video';
} else if (req.file.mimetype.startsWith('video/')) { else if (req.file.mimetype === 'application/pdf') mediaType = 'document';
mediaType = 'video';
} else if (req.file.mimetype === 'application/pdf') {
mediaType = 'document';
}
const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${req.file.filename}`;
// Create media record
const media = new Media({ const media = new Media({
filename: req.file.filename, filename: req.file.filename,
originalName: req.file.originalname, originalName: req.file.originalname,
url, url: `/images/${folder}/${req.file.filename}`,
mimeType: req.file.mimetype, mimeType: req.file.mimetype,
size: req.file.size, size: req.file.size,
type: mediaType, type: mediaType,
folder: folderSafe, folder,
alt, alt,
caption, caption,
description, description,
@@ -136,55 +108,37 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
await media.save(); await media.save();
res.status(201).json({ res.status(201).json({ success: true, message: 'File uploaded successfully', data: { media } });
success: true,
message: 'File uploaded successfully',
data: { media },
});
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: error.message || 'Error uploading file' });
success: false,
message: error.message || 'Error uploading file',
});
} }
}); });
// @route POST /api/media/upload-multiple // POST /api/media/upload-multiple
// @desc Upload multiple media files
// @access Private (Admin)
router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => { router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => {
try { try {
if (!req.files || req.files.length === 0) { if (!req.files || req.files.length === 0) {
return res.status(400).json({ return res.status(400).json({ success: false, message: 'No files uploaded' });
success: false,
message: 'No files uploaded',
});
} }
const folderSafe = sanitizeFolder(req.body.folder); const folder = safeFolder(req.body.folder || 'general');
const mediaRecords = []; const mediaRecords = [];
for (const file of req.files) { for (const file of req.files) {
let mediaType = 'other'; let mediaType = 'other';
if (file.mimetype.startsWith('image/')) { if (file.mimetype.startsWith('image/')) mediaType = 'image';
mediaType = 'image'; else if (file.mimetype.startsWith('video/')) mediaType = 'video';
} else if (file.mimetype.startsWith('video/')) { else if (file.mimetype === 'application/pdf') mediaType = 'document';
mediaType = 'video';
} else if (file.mimetype === 'application/pdf') {
mediaType = 'document';
}
const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${file.filename}`;
const media = new Media({ const media = new Media({
filename: file.filename, filename: file.filename,
originalName: file.originalname, originalName: file.originalname,
url, url: `/images/${folder}/${file.filename}`,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
type: mediaType, type: mediaType,
folder: folderSafe, folder,
uploadedBy: req.admin.id, uploadedBy: req.admin.id,
}); });
@@ -199,16 +153,11 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req
}); });
} catch (error) { } catch (error) {
console.error('Multiple upload error:', error); console.error('Multiple upload error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: error.message || 'Error uploading files' });
success: false,
message: error.message || 'Error uploading files',
});
} }
}); });
// @route GET /api/media // GET /api/media
// @desc Get all media
// @access Private (Admin)
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, async (req, res) => {
try { try {
const { folder, type, page = 1, limit = 50 } = req.query; const { folder, type, page = 1, limit = 50 } = req.query;
@@ -228,57 +177,29 @@ router.get('/', adminAuth, async (req, res) => {
res.json({ res.json({
success: true, success: true,
data: { data: { media, pagination: { page: parseInt(page, 10), limit: parseInt(limit, 10), total, pages: Math.ceil(total / limit) } },
media,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total,
pages: Math.ceil(total / limit),
},
},
}); });
} catch (error) { } catch (error) {
console.error('Get media error:', error); console.error('Get media error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error fetching media' });
success: false,
message: 'Error fetching media',
});
} }
}); });
// @route GET /api/media/search // GET /api/media/search
// @desc Search media
// @access Private (Admin)
router.get('/search', adminAuth, async (req, res) => { router.get('/search', adminAuth, async (req, res) => {
try { try {
const { q, folder, type, limit = 50 } = req.query; const { q, folder, type, limit = 50 } = req.query;
if (!q) return res.status(400).json({ success: false, message: 'Search query is required' });
if (!q) {
return res.status(400).json({
success: false,
message: 'Search query is required',
});
}
const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) }); const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) });
res.json({ success: true, data: { media } });
res.json({
success: true,
data: { media },
});
} catch (error) { } catch (error) {
console.error('Search media error:', error); console.error('Search media error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error searching media' });
success: false,
message: 'Error searching media',
});
} }
}); });
// @route PUT /api/media/:id // PUT /api/media/:id
// @desc Update media metadata
// @access Private (Admin)
router.put('/:id', adminAuth, async (req, res) => { router.put('/:id', adminAuth, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -289,107 +210,51 @@ router.put('/:id', adminAuth, async (req, res) => {
if (caption !== undefined) updateFields.caption = caption; if (caption !== undefined) updateFields.caption = caption;
if (description !== undefined) updateFields.description = description; if (description !== undefined) updateFields.description = description;
if (tags !== undefined) updateFields.tags = tags; if (tags !== undefined) updateFields.tags = tags;
if (folder !== undefined) updateFields.folder = sanitizeFolder(folder); if (folder !== undefined) updateFields.folder = safeFolder(folder);
const media = await Media.findByIdAndUpdate(id, { $set: updateFields }, { new: true, runValidators: true }); const media = await Media.findByIdAndUpdate(id, { $set: updateFields }, { new: true, runValidators: true });
if (!media) return res.status(404).json({ success: false, message: 'Media not found' });
if (!media) { res.json({ success: true, message: 'Media updated successfully', data: { media } });
return res.status(404).json({
success: false,
message: 'Media not found',
});
}
res.json({
success: true,
message: 'Media updated successfully',
data: { media },
});
} catch (error) { } catch (error) {
console.error('Update media error:', error); console.error('Update media error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error updating media' });
success: false,
message: 'Error updating media',
});
} }
}); });
const safeResolveInside = (base, rel) => { // DELETE /api/media/:id
const baseAbs = path.resolve(base);
const targetAbs = path.resolve(baseAbs, rel);
if (!targetAbs.startsWith(baseAbs + path.sep)) return null;
return targetAbs;
};
// @route DELETE /api/media/:id
// @desc Delete media file
// @access Private (Admin)
router.delete('/:id', adminAuth, async (req, res) => { router.delete('/:id', adminAuth, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const media = await Media.findById(id); const media = await Media.findById(id);
if (!media) return res.status(404).json({ success: false, message: 'Media not found' });
if (!media) { // media.url example: /images/general/file.jpg
return res.status(404).json({ const rel = media.url.replace(/^\/images\//, '');
success: false, const filePath = path.join(imagesRoot, rel);
message: 'Media not found',
});
}
// Delete file from filesystem (only if inside UPLOAD_DIR)
try { try {
const url = String(media.url || ''); await fs.unlink(filePath);
let rel = null;
if (url.startsWith(PUBLIC_BASE + '/')) {
rel = url.slice((PUBLIC_BASE + '/').length);
} else if (url.startsWith('/uploads/')) {
rel = url.slice('/uploads/'.length);
}
if (rel) {
const filePath = safeResolveInside(UPLOAD_DIR, rel);
if (filePath && fsNative.existsSync(filePath)) {
await fs.unlink(filePath);
}
}
} catch (err) { } catch (err) {
console.error('Error deleting file:', err); console.error('Error deleting file:', err);
} }
await Media.findByIdAndDelete(id); await Media.findByIdAndDelete(id);
res.json({ success: true, message: 'Media deleted successfully' });
res.json({
success: true,
message: 'Media deleted successfully',
});
} catch (error) { } catch (error) {
console.error('Delete media error:', error); console.error('Delete media error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error deleting media' });
success: false,
message: 'Error deleting media',
});
} }
}); });
// @route GET /api/media/folders/list // GET /api/media/folders/list
// @desc Get list of all folders
// @access Private (Admin)
router.get('/folders/list', adminAuth, async (req, res) => { router.get('/folders/list', adminAuth, async (req, res) => {
try { try {
const folders = await Media.distinct('folder'); const folders = await Media.distinct('folder');
res.json({ success: true, data: { folders } });
res.json({
success: true,
data: { folders },
});
} catch (error) { } catch (error) {
console.error('Get folders error:', error); console.error('Get folders error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error fetching folders' });
success: false,
message: 'Error fetching folders',
});
} }
}); });

View File

@@ -6,115 +6,101 @@ const fs = require('fs');
const sharp = require('sharp'); const sharp = require('sharp');
const adminAuth = require('../middleware/adminAuth'); const adminAuth = require('../middleware/adminAuth');
// ======================= const isProd = process.env.NODE_ENV === 'production';
// Config (LOCAL + PROD)
// =======================
const UPLOAD_DIR = process.env.UPLOAD_DIR
? path.resolve(process.env.UPLOAD_DIR)
: path.resolve(__dirname, '..', 'uploads'); // local fallback
const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; // In dev: oldvine_cms/client/public
const devPublicRoot = path.join(__dirname, '../../client/public');
// On server: nginx serves /uploads from /var/www/oldvine/uploads (matches current nginx config)
const uploadsDir =
process.env.UPLOADS_DIR ||
(isProd ? '/var/www/oldvine/uploads' : path.join(devPublicRoot, 'uploads'));
// Ensure uploads directory exists // Ensure uploads directory exists
if (!fs.existsSync(UPLOAD_DIR)) { if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true }); fs.mkdirSync(uploadsDir, { recursive: true });
} }
const safeBaseName = (name) =>
String(name || 'file')
.toLowerCase()
.replace(/\.[^/.]+$/, '') // remove extension
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const pickOutput = (mimetype) => {
if (mimetype === 'image/png') return { ext: '.png', fmt: 'png' };
if (mimetype === 'image/webp') return { ext: '.webp', fmt: 'webp' };
// default jpeg for jpg/jpeg/gif/others
return { ext: '.jpg', fmt: 'jpeg' };
};
// Configure multer for file upload // Configure multer for file upload
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: (req, file, cb) => { destination: (req, file, cb) => cb(null, uploadsDir),
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => { filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; const base = safeBaseName(file.originalname);
const name = path const { ext } = pickOutput(file.mimetype);
.basename(file.originalname, ext) cb(null, `${base || 'upload'}-${uniqueSuffix}${ext}`);
.replace(/[^a-z0-9]/gi, '-')
.toLowerCase()
.replace(/-+/g, '-')
.replace(/(^-|-$)/g, '');
cb(null, `${name || 'image'}-${uniqueSuffix}${ext}`);
}, },
}); });
const fileFilter = (req, file, cb) => { const fileFilter = (req, file, cb) => {
// Accept images only if (file.mimetype && file.mimetype.startsWith('image/')) return cb(null, true);
if (file.mimetype && file.mimetype.startsWith('image/')) { cb(new Error('Only image files are allowed'), false);
cb(null, true);
} else {
cb(new Error('Only image files are allowed'), false);
}
}; };
const upload = multer({ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileSize: 10 * 1024 * 1024, // 10MB limit
},
}); });
const optimizeImageInPlace = async (filePath, ext) => {
const lowerExt = (ext || '').toLowerCase();
// GIF: skip optimization (sharp may drop animation)
if (lowerExt === '.gif') return;
const tmpPath = filePath + '.tmp';
const pipeline = sharp(filePath)
.rotate()
.resize(1920, 1080, {
fit: 'inside',
withoutEnlargement: true,
});
if (lowerExt === '.png') {
await pipeline.png({ compressionLevel: 9 }).toFile(tmpPath);
} else if (lowerExt === '.webp') {
await pipeline.webp({ quality: 85 }).toFile(tmpPath);
} else {
// jpg/jpeg + anything else
await pipeline.jpeg({ quality: 85 }).toFile(tmpPath);
}
fs.renameSync(tmpPath, filePath);
};
// @route POST /api/upload // @route POST /api/upload
// @desc Upload single or multiple images // @desc Upload single or multiple images
// @access Private (Admin) // @access Private (Admin)
router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
try { try {
if (!req.files || req.files.length === 0) { if (!req.files || req.files.length === 0) {
return res.status(400).json({ return res.status(400).json({ success: false, message: 'No files uploaded' });
success: false,
message: 'No files uploaded',
});
} }
const uploadedFiles = await Promise.all( const uploadedFiles = await Promise.all(
req.files.map(async (file) => { req.files.map(async (file) => {
try { try {
const ext = path.extname(file.filename); const tmpPath = `${file.path}.opt`;
await optimizeImageInPlace(file.path, ext); const { fmt } = pickOutput(file.mimetype);
let img = sharp(file.path).resize(1920, 1080, {
fit: 'inside',
withoutEnlargement: true,
});
if (fmt === 'png') img = img.png({ compressionLevel: 9 });
else if (fmt === 'webp') img = img.webp({ quality: 85 });
else img = img.jpeg({ quality: 85 });
await img.toFile(tmpPath);
fs.unlinkSync(file.path);
fs.renameSync(tmpPath, file.path);
return { return {
filename: file.filename, filename: file.filename,
originalName: file.originalname, originalName: file.originalname,
url: `${PUBLIC_BASE}/${file.filename}`, url: `/uploads/${file.filename}`,
size: fs.statSync(file.path).size, size: fs.statSync(file.path).size,
mimeType: file.mimetype, mimeType: file.mimetype,
uploadedAt: new Date(), uploadedAt: new Date(),
}; };
} catch (error) { } catch (err) {
console.error('Error processing image:', error); console.error('Error processing image:', err);
return { return {
filename: file.filename, filename: file.filename,
originalName: file.originalname, originalName: file.originalname,
url: `${PUBLIC_BASE}/${file.filename}`, url: `/uploads/${file.filename}`,
size: file.size, size: file.size,
mimeType: file.mimetype, mimeType: file.mimetype,
uploadedAt: new Date(), uploadedAt: new Date(),
@@ -124,91 +110,51 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
}) })
); );
res.json({ res.json({ success: true, message: 'Files uploaded successfully', data: { files: uploadedFiles } });
success: true,
message: 'Files uploaded successfully',
data: { files: uploadedFiles },
});
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: error.message || 'Error uploading files' });
success: false,
message: error.message || 'Error uploading files',
});
} }
}); });
// @route GET /api/upload/list // @route GET /api/upload/list
// @desc Get list of uploaded files
// @access Private (Admin)
router.get('/list', adminAuth, async (req, res) => { router.get('/list', adminAuth, async (req, res) => {
try { try {
const files = fs.readdirSync(UPLOAD_DIR); const files = fs.readdirSync(uploadsDir);
const fileList = files const fileList = files
.filter((file) => !file.startsWith('.')) .filter((f) => !f.startsWith('.'))
.map((filename) => { .map((filename) => {
const filePath = path.join(UPLOAD_DIR, filename); const filePath = path.join(uploadsDir, filename);
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
return { filename, url: `/uploads/${filename}`, size: stats.size, uploadedAt: stats.mtime };
return {
filename,
url: `${PUBLIC_BASE}/${filename}`,
size: stats.size,
uploadedAt: stats.mtime,
};
}) })
.sort((a, b) => b.uploadedAt - a.uploadedAt); .sort((a, b) => b.uploadedAt - a.uploadedAt);
res.json({ res.json({ success: true, data: { files: fileList, total: fileList.length } });
success: true,
data: { files: fileList, total: fileList.length },
});
} catch (error) { } catch (error) {
console.error('List files error:', error); console.error('List files error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error listing files' });
success: false,
message: 'Error listing files',
});
} }
}); });
// @route DELETE /api/upload/:filename // @route DELETE /api/upload/:filename
// @desc Delete an uploaded file
// @access Private (Admin)
router.delete('/:filename', adminAuth, async (req, res) => { router.delete('/:filename', adminAuth, async (req, res) => {
try { try {
const { filename } = req.params; const { filename } = req.params;
// Security check
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ return res.status(400).json({ success: false, message: 'Invalid filename' });
success: false,
message: 'Invalid filename',
});
} }
const filePath = path.join(UPLOAD_DIR, filename); const filePath = path.join(uploadsDir, filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ success: false, message: 'File not found' });
if (!fs.existsSync(filePath)) {
return res.status(404).json({
success: false,
message: 'File not found',
});
}
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
res.json({ success: true, message: 'File deleted successfully' });
res.json({
success: true,
message: 'File deleted successfully',
});
} catch (error) { } catch (error) {
console.error('Delete file error:', error); console.error('Delete file error:', error);
res.status(500).json({ res.status(500).json({ success: false, message: 'Error deleting file' });
success: false,
message: 'Error deleting file',
});
} }
}); });