From 3c3aef54461fb6c8f56b713b9d4859c393a7d3d0 Mon Sep 17 00:00:00 2001 From: yotakii Date: Wed, 18 Feb 2026 15:40:31 +0300 Subject: [PATCH] edit media --- routes/media.js | 133 ++++++++++++++++------------------------ routes/upload.js | 155 +++++++++++++++++++++-------------------------- 2 files changed, 121 insertions(+), 167 deletions(-) diff --git a/routes/media.js b/routes/media.js index 11073a9..0ce97ba 100644 --- a/routes/media.js +++ b/routes/media.js @@ -6,17 +6,17 @@ const fs = require('fs').promises; const Media = require('../models/Media'); const adminAuth = require('../middleware/adminAuth'); -// --- Helpers --- -const getUploadsDir = () => { - const envDir = (process.env.UPLOADS_DIR || '').trim(); - if (envDir) return path.isAbsolute(envDir) ? envDir : path.resolve(process.cwd(), envDir); - return path.resolve(__dirname, '..', 'uploads'); -}; +const isProd = process.env.NODE_ENV === 'production'; -const baseDir = getUploadsDir(); -const urlPrefix = (process.env.UPLOADS_URL_PREFIX || '/uploads').replace(/\/$/, ''); +// In dev: oldvine_cms/client/public/images +const devImagesRoot = path.join(__dirname, '../../client/public/images'); -const ensureUploadDir = async (dir) => { +// On server: nginx serves /images from /var/www/oldvine/images (recommended) +const imagesRoot = + process.env.IMAGES_DIR || + (isProd ? '/var/www/oldvine/images' : devImagesRoot); + +const ensureDir = async (dir) => { try { await fs.access(dir); } catch { @@ -24,46 +24,40 @@ const ensureUploadDir = async (dir) => { } }; -const sanitizeFolder = (folder) => { - const f = String(folder || 'general').trim().toLowerCase(); - return f.replace(/[^a-z0-9/_-]/g, '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '') || 'general'; -}; - -const resolveSafePath = (root, relative) => { - const safeRel = String(relative || '').replace(/^\/+/, ''); - const full = path.resolve(root, safeRel); - const rootResolved = path.resolve(root); - if (!full.startsWith(rootResolved + path.sep) && full !== rootResolved) { - throw new Error('Invalid path'); - } - return full; -}; +const safeFolder = (s) => + String(s || 'general') + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'general'; // Configure multer storage const storage = multer.diskStorage({ - destination: (req, file, cb) => { - const folder = sanitizeFolder(req.body.folder || 'general'); - const uploadPath = path.join(baseDir, folder); - - ensureUploadDir(uploadPath) - .then(() => cb(null, uploadPath)) - .catch((e) => cb(e)); + destination: async (req, file, cb) => { + try { + const folder = safeFolder(req.body.folder || 'general'); + const uploadPath = path.join(imagesRoot, folder); + await ensureDir(uploadPath); + cb(null, uploadPath); + } catch (err) { + cb(err); + } }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(file.originalname).toLowerCase(); + const ext = path.extname(file.originalname); const basename = path .basename(file.originalname, ext) .toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); - cb(null, `${basename || 'file'}-${uniqueSuffix}${ext || ''}`); + cb(null, `${basename || 'media'}-${uniqueSuffix}${ext.toLowerCase()}`); }, }); const fileFilter = (req, file, cb) => { - const allowedMimeTypes = [ + const allowed = [ 'image/jpeg', 'image/jpg', 'image/png', @@ -74,27 +68,22 @@ const fileFilter = (req, file, cb) => { 'video/quicktime', 'application/pdf', ]; - - if (allowedMimeTypes.includes(file.mimetype)) return cb(null, true); + if (allowed.includes(file.mimetype)) return cb(null, true); cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false); }; const upload = multer({ storage, fileFilter, - limits: { fileSize: 10 * 1024 * 1024 }, // 10MB + limits: { fileSize: 10 * 1024 * 1024 }, }); -// @route POST /api/media/upload -// @desc Upload media file -// @access Private (Admin) +// POST /api/media/upload router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { try { - if (!req.file) { - return res.status(400).json({ success: false, message: 'No file uploaded' }); - } + if (!req.file) return res.status(400).json({ success: false, message: 'No file uploaded' }); - const folder = sanitizeFolder(req.body.folder || 'general'); + const folder = safeFolder(req.body.folder || 'general'); const { alt, caption, description, tags } = req.body; let mediaType = 'other'; @@ -105,7 +94,7 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { const media = new Media({ filename: req.file.filename, originalName: req.file.originalname, - url: `${urlPrefix}/${folder}/${req.file.filename}`, + url: `/images/${folder}/${req.file.filename}`, mimeType: req.file.mimetype, size: req.file.size, type: mediaType, @@ -113,31 +102,27 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { alt, caption, description, - tags: tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [], + tags: tags ? tags.split(',').map((t) => t.trim()) : [], uploadedBy: req.admin.id, }); await media.save(); - res.status(201).json({ - success: true, - message: 'File uploaded successfully', - data: { media }, - }); + res.status(201).json({ success: true, message: 'File uploaded successfully', data: { media } }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, message: error.message || 'Error uploading file' }); } }); -// @route POST /api/media/upload-multiple +// POST /api/media/upload-multiple router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ success: false, message: 'No files uploaded' }); } - const folder = sanitizeFolder(req.body.folder || 'general'); + const folder = safeFolder(req.body.folder || 'general'); const mediaRecords = []; for (const file of req.files) { @@ -149,7 +134,7 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req const media = new Media({ filename: file.filename, originalName: file.originalname, - url: `${urlPrefix}/${folder}/${file.filename}`, + url: `/images/${folder}/${file.filename}`, mimeType: file.mimetype, size: file.size, type: mediaType, @@ -172,7 +157,7 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req } }); -// @route GET /api/media +// GET /api/media router.get('/', adminAuth, async (req, res) => { try { const { folder, type, page = 1, limit = 50 } = req.query; @@ -192,15 +177,7 @@ router.get('/', adminAuth, async (req, res) => { res.json({ success: true, - data: { - media, - pagination: { - page: parseInt(page, 10), - limit: parseInt(limit, 10), - total, - pages: Math.ceil(total / limit), - }, - }, + data: { media, pagination: { page: parseInt(page, 10), limit: parseInt(limit, 10), total, pages: Math.ceil(total / limit) } }, }); } catch (error) { console.error('Get media error:', error); @@ -208,17 +185,13 @@ router.get('/', adminAuth, async (req, res) => { } }); -// @route GET /api/media/search +// GET /api/media/search router.get('/search', adminAuth, async (req, res) => { try { 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) }); - res.json({ success: true, data: { media } }); } catch (error) { console.error('Search media error:', error); @@ -226,7 +199,7 @@ router.get('/search', adminAuth, async (req, res) => { } }); -// @route PUT /api/media/:id +// PUT /api/media/:id router.put('/:id', adminAuth, async (req, res) => { try { const { id } = req.params; @@ -237,10 +210,9 @@ router.put('/:id', adminAuth, async (req, res) => { if (caption !== undefined) updateFields.caption = caption; if (description !== undefined) updateFields.description = description; 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 }); - if (!media) return res.status(404).json({ success: false, message: 'Media not found' }); res.json({ success: true, message: 'Media updated successfully', data: { media } }); @@ -250,27 +222,24 @@ router.put('/:id', adminAuth, async (req, res) => { } }); -// @route DELETE /api/media/:id +// DELETE /api/media/:id router.delete('/:id', adminAuth, async (req, res) => { try { const { id } = req.params; const media = await Media.findById(id); - if (!media) return res.status(404).json({ success: false, message: 'Media not found' }); - // Delete file from filesystem (based on /uploads/.. url) + // media.url example: /images/general/file.jpg + const rel = media.url.replace(/^\/images\//, ''); + const filePath = path.join(imagesRoot, rel); + try { - if (media.url && media.url.startsWith(urlPrefix + '/')) { - const relative = media.url.slice(urlPrefix.length + 1); // remove "/uploads/" - const filePath = resolveSafePath(baseDir, relative); - await fs.unlink(filePath); - } + await fs.unlink(filePath); } catch (err) { console.error('Error deleting file:', err); } await Media.findByIdAndDelete(id); - res.json({ success: true, message: 'Media deleted successfully' }); } catch (error) { console.error('Delete media error:', error); @@ -278,7 +247,7 @@ router.delete('/:id', adminAuth, async (req, res) => { } }); -// @route GET /api/media/folders/list +// GET /api/media/folders/list router.get('/folders/list', adminAuth, async (req, res) => { try { const folders = await Media.distinct('folder'); diff --git a/routes/upload.js b/routes/upload.js index 71e73e6..1fab7d5 100644 --- a/routes/upload.js +++ b/routes/upload.js @@ -6,40 +6,49 @@ const fs = require('fs'); const sharp = require('sharp'); const adminAuth = require('../middleware/adminAuth'); -// --- Helpers --- -const getUploadsDir = () => { - const envDir = (process.env.UPLOADS_DIR || '').trim(); - if (envDir) return path.isAbsolute(envDir) ? envDir : path.resolve(process.cwd(), envDir); - return path.resolve(__dirname, '..', 'uploads'); -}; +const isProd = process.env.NODE_ENV === 'production'; -const uploadsDir = getUploadsDir(); -const urlPrefix = (process.env.UPLOADS_URL_PREFIX || '/uploads').replace(/\/$/, ''); +// In dev: oldvine_cms/client/public +const devPublicRoot = path.join(__dirname, '../../client/public'); + +// On server: nginx serves /uploads from /var/www/oldvine_uploads (recommended) +const uploadsDir = + process.env.UPLOADS_DIR || + (isProd ? '/var/www/oldvine_uploads' : path.join(devPublicRoot, 'uploads')); // Ensure uploads directory exists if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } +const safeBaseName = (name) => + String(name || 'file') + .toLowerCase() + .replace(/\.[^/.]+$/, '') // remove extension + .replace(/[^a-z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + +// Pick output format so extension matches actual bytes (important!) +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 const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadsDir), filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(file.originalname).toLowerCase(); - const name = path - .basename(file.originalname, ext) - .replace(/[^a-z0-9]/gi, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .toLowerCase(); - - cb(null, `${name || 'file'}-${uniqueSuffix}${ext || ''}`); + const base = safeBaseName(file.originalname); + const { ext } = pickOutput(file.mimetype); + cb(null, `${base || 'upload'}-${uniqueSuffix}${ext}`); }, }); const fileFilter = (req, file, cb) => { - // Accept images only if (file.mimetype && file.mimetype.startsWith('image/')) return cb(null, true); cb(new Error('Only image files are allowed'), false); }; @@ -50,29 +59,6 @@ const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 }, // 10MB }); -async function optimizeImageIfPossible(filePath, extLower) { - if (extLower === '.gif') return; - - const tmpPath = `${filePath}.tmp`; - - let pipeline = sharp(filePath).resize(1920, 1080, { - fit: 'inside', - withoutEnlargement: true, - }); - - if (extLower === '.png') { - pipeline = pipeline.png({ quality: 85, compressionLevel: 9 }); - } else if (extLower === '.webp') { - pipeline = pipeline.webp({ quality: 85 }); - } else { - pipeline = pipeline.jpeg({ quality: 85 }); - } - - await pipeline.toFile(tmpPath); - fs.unlinkSync(filePath); - fs.renameSync(tmpPath, filePath); -} - // @route POST /api/upload // @desc Upload single or multiple images // @access Private (Admin) @@ -82,38 +68,50 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { return res.status(400).json({ success: false, message: 'No files uploaded' }); } - const uploadedFiles = []; + const uploadedFiles = await Promise.all( + req.files.map(async (file) => { + try { + const tmpPath = `${file.path}.opt`; + const { fmt } = pickOutput(file.mimetype); - for (const file of req.files) { - try { - const extLower = path.extname(file.filename).toLowerCase(); - await optimizeImageIfPossible(file.path, extLower); + let img = sharp(file.path).resize(1920, 1080, { + fit: 'inside', + withoutEnlargement: true, + }); - uploadedFiles.push({ - filename: file.filename, - originalName: file.originalname, - url: `${urlPrefix}/${file.filename}`, - mimeType: file.mimetype, - uploadedAt: new Date(), - }); - } catch (e) { - console.error('Error processing image:', e); - uploadedFiles.push({ - filename: file.filename, - originalName: file.originalname, - url: `${urlPrefix}/${file.filename}`, - mimeType: file.mimetype, - uploadedAt: new Date(), - error: 'Optimization failed', - }); - } - } + if (fmt === 'png') img = img.png({ compressionLevel: 9 }); + else if (fmt === 'webp') img = img.webp({ quality: 85 }); + else img = img.jpeg({ quality: 85 }); - res.json({ - success: true, - message: 'Files uploaded successfully', - data: { files: uploadedFiles }, - }); + await img.toFile(tmpPath); + + fs.unlinkSync(file.path); + fs.renameSync(tmpPath, file.path); + + return { + filename: file.filename, + originalName: file.originalname, + url: `/uploads/${file.filename}`, + size: fs.statSync(file.path).size, + mimeType: file.mimetype, + uploadedAt: new Date(), + }; + } catch (err) { + console.error('Error processing image:', err); + return { + filename: file.filename, + originalName: file.originalname, + url: `/uploads/${file.filename}`, + size: file.size, + mimeType: file.mimetype, + uploadedAt: new Date(), + error: 'Optimization failed', + }; + } + }) + ); + + res.json({ success: true, message: 'Files uploaded successfully', data: { files: uploadedFiles } }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, message: error.message || 'Error uploading files' }); @@ -121,8 +119,6 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { }); // @route GET /api/upload/list -// @desc Get list of uploaded files -// @access Private (Admin) router.get('/list', adminAuth, async (req, res) => { try { const files = fs.readdirSync(uploadsDir); @@ -132,12 +128,7 @@ router.get('/list', adminAuth, async (req, res) => { .map((filename) => { const filePath = path.join(uploadsDir, filename); const stats = fs.statSync(filePath); - return { - filename, - url: `${urlPrefix}/${filename}`, - size: stats.size, - uploadedAt: stats.mtime, - }; + return { filename, url: `/uploads/${filename}`, size: stats.size, uploadedAt: stats.mtime }; }) .sort((a, b) => b.uploadedAt - a.uploadedAt); @@ -149,22 +140,16 @@ router.get('/list', adminAuth, async (req, res) => { }); // @route DELETE /api/upload/:filename -// @desc Delete an uploaded file -// @access Private (Admin) router.delete('/:filename', adminAuth, async (req, res) => { try { const { filename } = req.params; - // Security check: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return res.status(400).json({ success: false, message: 'Invalid 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); res.json({ success: true, message: 'File deleted successfully' });