edit upload & media file

This commit is contained in:
yotakii
2026-02-15 14:21:19 +03:00
parent fb2bec5c62
commit 6417fd6b01
2 changed files with 191 additions and 118 deletions

View File

@@ -2,10 +2,22 @@ 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');
// =======================
// 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';
const MEDIA_ROOT = process.env.MEDIA_SUBDIR || 'images';
// Ensure upload directories exist // Ensure upload directories exist
const ensureUploadDir = async (dir) => { const ensureUploadDir = async (dir) => {
try { try {
@@ -15,24 +27,39 @@ const ensureUploadDir = async (dir) => {
} }
}; };
const sanitizeFolder = (folder) => {
let f = String(folder || 'general').trim();
// allow a-z0-9 _ - and slashes for nested folders
f = f.replace(/[^a-z0-9/_-]/gi, '');
// prevent traversal
while (f.includes('..')) f = f.replace(/\.\./g, '');
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) => {
const folder = req.body.folder || 'general'; try {
const uploadPath = path.join(__dirname, '../../client/public/images', folder); const folderSafe = sanitizeFolder(req.body.folder);
const uploadPath = path.join(UPLOAD_DIR, MEDIA_ROOT, folderSafe);
await ensureUploadDir(uploadPath); await ensureUploadDir(uploadPath);
cb(null, uploadPath); cb(null, uploadPath);
} catch (e) {
cb(e);
}
}, },
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); const ext = path.extname(file.originalname);
const basename = path.basename(file.originalname, ext) const basename = path
.basename(file.originalname, ext)
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9]/g, '-') .replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-') .replace(/-+/g, '-')
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '');
cb(null, basename + '-' + uniqueSuffix + ext); cb(null, (basename || 'file') + '-' + uniqueSuffix + ext.toLowerCase());
} },
}); });
// File filter // File filter
@@ -46,7 +73,7 @@ const fileFilter = (req, file, cb) => {
'image/svg+xml', 'image/svg+xml',
'video/mp4', 'video/mp4',
'video/quicktime', 'video/quicktime',
'application/pdf' 'application/pdf',
]; ];
if (allowedMimeTypes.includes(file.mimetype)) { if (allowedMimeTypes.includes(file.mimetype)) {
@@ -60,8 +87,8 @@ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { limits: {
fileSize: 10 * 1024 * 1024 // 10MB limit fileSize: 10 * 1024 * 1024, // 10MB limit
} },
}); });
// @route POST /api/media/upload // @route POST /api/media/upload
@@ -72,11 +99,12 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'No file uploaded' message: 'No file uploaded',
}); });
} }
const { folder = 'general', alt, caption, description, tags } = req.body; const folderSafe = sanitizeFolder(req.body.folder);
const { alt, caption, description, tags } = req.body;
// Determine media type // Determine media type
let mediaType = 'other'; let mediaType = 'other';
@@ -88,20 +116,22 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
mediaType = 'document'; mediaType = 'document';
} }
const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${req.file.filename}`;
// Create media record // 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: `/images/${folder}/${req.file.filename}`, url,
mimeType: req.file.mimetype, mimeType: req.file.mimetype,
size: req.file.size, size: req.file.size,
type: mediaType, type: mediaType,
folder, folder: folderSafe,
alt, alt,
caption, caption,
description, description,
tags: tags ? tags.split(',').map(t => t.trim()) : [], tags: tags ? tags.split(',').map((t) => t.trim()) : [],
uploadedBy: req.admin.id uploadedBy: req.admin.id,
}); });
await media.save(); await media.save();
@@ -109,13 +139,13 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => {
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: 'File uploaded successfully', message: 'File uploaded successfully',
data: { media } 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, success: false,
message: error.message || 'Error uploading file' message: error.message || 'Error uploading file',
}); });
} }
}); });
@@ -128,11 +158,11 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req
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, success: false,
message: 'No files uploaded' message: 'No files uploaded',
}); });
} }
const { folder = 'general' } = req.body; const folderSafe = sanitizeFolder(req.body.folder);
const mediaRecords = []; const mediaRecords = [];
for (const file of req.files) { for (const file of req.files) {
@@ -145,15 +175,17 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req
mediaType = 'document'; 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: `/images/${folder}/${file.filename}`, url,
mimeType: file.mimetype, mimeType: file.mimetype,
size: file.size, size: file.size,
type: mediaType, type: mediaType,
folder, folder: folderSafe,
uploadedBy: req.admin.id uploadedBy: req.admin.id,
}); });
await media.save(); await media.save();
@@ -163,13 +195,13 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req
res.status(201).json({ res.status(201).json({
success: true, success: true,
message: `${mediaRecords.length} files uploaded successfully`, message: `${mediaRecords.length} files uploaded successfully`,
data: { media: mediaRecords } data: { media: mediaRecords },
}); });
} 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, success: false,
message: error.message || 'Error uploading files' message: error.message || 'Error uploading files',
}); });
} }
}); });
@@ -190,7 +222,7 @@ router.get('/', adminAuth, async (req, res) => {
.populate('uploadedBy', 'firstName lastName') .populate('uploadedBy', 'firstName lastName')
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.skip(skip) .skip(skip)
.limit(parseInt(limit)); .limit(parseInt(limit, 10));
const total = await Media.countDocuments(query); const total = await Media.countDocuments(query);
@@ -199,18 +231,18 @@ router.get('/', adminAuth, async (req, res) => {
data: { data: {
media, media,
pagination: { pagination: {
page: parseInt(page), page: parseInt(page, 10),
limit: parseInt(limit), limit: parseInt(limit, 10),
total, total,
pages: Math.ceil(total / limit) 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, success: false,
message: 'Error fetching media' message: 'Error fetching media',
}); });
} }
}); });
@@ -225,21 +257,21 @@ router.get('/search', adminAuth, async (req, res) => {
if (!q) { if (!q) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Search query is required' message: 'Search query is required',
}); });
} }
const media = await Media.search(q, { folder, type, limit: parseInt(limit) }); const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) });
res.json({ res.json({
success: true, success: true,
data: { media } 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, success: false,
message: 'Error searching media' message: 'Error searching media',
}); });
} }
}); });
@@ -253,39 +285,42 @@ router.put('/:id', adminAuth, async (req, res) => {
const { alt, caption, description, tags, folder } = req.body; const { alt, caption, description, tags, folder } = req.body;
const updateFields = {}; const updateFields = {};
if (alt) updateFields.alt = alt; if (alt !== undefined) updateFields.alt = alt;
if (caption) updateFields.caption = caption; if (caption !== undefined) updateFields.caption = caption;
if (description) updateFields.description = description; if (description !== undefined) updateFields.description = description;
if (tags) updateFields.tags = tags; if (tags !== undefined) updateFields.tags = tags;
if (folder) updateFields.folder = folder; if (folder !== undefined) updateFields.folder = sanitizeFolder(folder);
const media = await Media.findByIdAndUpdate( const media = await Media.findByIdAndUpdate(id, { $set: updateFields }, { new: true, runValidators: true });
id,
{ $set: updateFields },
{ new: true, runValidators: true }
);
if (!media) { if (!media) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Media not found' message: 'Media not found',
}); });
} }
res.json({ res.json({
success: true, success: true,
message: 'Media updated successfully', message: 'Media updated successfully',
data: { media } 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, success: false,
message: 'Error updating media' message: 'Error updating media',
}); });
} }
}); });
const safeResolveInside = (base, rel) => {
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 // @route DELETE /api/media/:id
// @desc Delete media file // @desc Delete media file
// @access Private (Admin) // @access Private (Admin)
@@ -298,30 +333,42 @@ router.delete('/:id', adminAuth, async (req, res) => {
if (!media) { if (!media) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'Media not found' message: 'Media not found',
}); });
} }
// Delete file from filesystem // Delete file from filesystem (only if inside UPLOAD_DIR)
const filePath = path.join(__dirname, '../../client/public', media.url);
try { try {
const url = String(media.url || '');
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); await fs.unlink(filePath);
}
}
} catch (err) { } catch (err) {
console.error('Error deleting file:', err); console.error('Error deleting file:', err);
} }
// Delete media record
await Media.findByIdAndDelete(id); await Media.findByIdAndDelete(id);
res.json({ res.json({
success: true, success: true,
message: 'Media deleted successfully' 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, success: false,
message: 'Error deleting media' message: 'Error deleting media',
}); });
} }
}); });
@@ -335,16 +382,15 @@ router.get('/folders/list', adminAuth, async (req, res) => {
res.json({ res.json({
success: true, success: true,
data: { folders } 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, success: false,
message: 'Error fetching folders' message: 'Error fetching folders',
}); });
} }
}); });
module.exports = router; module.exports = router;

View File

@@ -6,29 +6,42 @@ const fs = require('fs');
const sharp = require('sharp'); const sharp = require('sharp');
const adminAuth = require('../middleware/adminAuth'); const adminAuth = require('../middleware/adminAuth');
// =======================
// 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';
// Ensure uploads directory exists // Ensure uploads directory exists
const uploadsDir = path.join(__dirname, '../../client/public/uploads'); if (!fs.existsSync(UPLOAD_DIR)) {
if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(uploadsDir, { recursive: true });
} }
// 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) => {
// Generate unique filename 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 ext = path.extname(file.originalname); const name = path
const name = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-').toLowerCase(); .basename(file.originalname, ext)
cb(null, `${name}-${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 // Accept images only
if (file.mimetype.startsWith('image/')) { if (file.mimetype && file.mimetype.startsWith('image/')) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only image files are allowed'), false); cb(new Error('Only image files are allowed'), false);
@@ -36,13 +49,40 @@ const fileFilter = (req, file, cb) => {
}; };
const upload = multer({ const upload = multer({
storage: storage, storage,
fileFilter: fileFilter, fileFilter,
limits: { limits: {
fileSize: 10 * 1024 * 1024 // 10MB limit 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)
@@ -51,47 +91,34 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
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, success: false,
message: 'No files uploaded' message: 'No files uploaded',
}); });
} }
// Process uploaded files
const uploadedFiles = await Promise.all( const uploadedFiles = await Promise.all(
req.files.map(async (file) => { req.files.map(async (file) => {
try { try {
// Optimize image with sharp const ext = path.extname(file.filename);
const optimizedPath = path.join(uploadsDir, `optimized-${file.filename}`); await optimizeImageInPlace(file.path, ext);
await sharp(file.path)
.resize(1920, 1080, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85 })
.toFile(optimizedPath);
// Replace original with optimized
fs.unlinkSync(file.path);
fs.renameSync(optimizedPath, file.path);
return { return {
filename: file.filename, filename: file.filename,
originalName: file.originalname, originalName: file.originalname,
url: `/uploads/${file.filename}`, url: `${PUBLIC_BASE}/${file.filename}`,
size: file.size, size: fs.statSync(file.path).size,
mimeType: file.mimetype, mimeType: file.mimetype,
uploadedAt: new Date() uploadedAt: new Date(),
}; };
} catch (error) { } catch (error) {
console.error('Error processing image:', error); console.error('Error processing image:', error);
return { return {
filename: file.filename, filename: file.filename,
originalName: file.originalname, originalName: file.originalname,
url: `/uploads/${file.filename}`, url: `${PUBLIC_BASE}/${file.filename}`,
size: file.size, size: file.size,
mimeType: file.mimetype, mimeType: file.mimetype,
uploadedAt: new Date(), uploadedAt: new Date(),
error: 'Optimization failed' error: 'Optimization failed',
}; };
} }
}) })
@@ -100,13 +127,13 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
res.json({ res.json({
success: true, success: true,
message: 'Files uploaded successfully', message: 'Files uploaded successfully',
data: { files: uploadedFiles } 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, success: false,
message: error.message || 'Error uploading files' message: error.message || 'Error uploading files',
}); });
} }
}); });
@@ -116,32 +143,32 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => {
// @access Private (Admin) // @access Private (Admin)
router.get('/list', adminAuth, async (req, res) => { router.get('/list', adminAuth, async (req, res) => {
try { try {
const files = fs.readdirSync(uploadsDir); const files = fs.readdirSync(UPLOAD_DIR);
const fileList = files const fileList = files
.filter(file => !file.startsWith('.')) // Ignore hidden files .filter((file) => !file.startsWith('.'))
.map(filename => { .map((filename) => {
const filePath = path.join(uploadsDir, filename); const filePath = path.join(UPLOAD_DIR, filename);
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
return { return {
filename, filename,
url: `/uploads/${filename}`, url: `${PUBLIC_BASE}/${filename}`,
size: stats.size, size: stats.size,
uploadedAt: stats.mtime uploadedAt: stats.mtime,
}; };
}) })
.sort((a, b) => b.uploadedAt - a.uploadedAt); // Sort by newest first .sort((a, b) => b.uploadedAt - a.uploadedAt);
res.json({ res.json({
success: true, success: true,
data: { files: fileList, total: fileList.length } 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, success: false,
message: 'Error listing files' message: 'Error listing files',
}); });
} }
}); });
@@ -152,20 +179,21 @@ router.get('/list', adminAuth, async (req, res) => {
router.delete('/:filename', adminAuth, async (req, res) => { router.delete('/:filename', adminAuth, async (req, res) => {
try { try {
const { filename } = req.params; const { filename } = req.params;
const filePath = path.join(uploadsDir, filename);
// Security check: ensure filename doesn't contain path traversal // 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, success: false,
message: 'Invalid filename' message: 'Invalid filename',
}); });
} }
const filePath = path.join(UPLOAD_DIR, filename);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: 'File not found' message: 'File not found',
}); });
} }
@@ -173,16 +201,15 @@ router.delete('/:filename', adminAuth, async (req, res) => {
res.json({ res.json({
success: true, success: true,
message: 'File deleted successfully' 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, success: false,
message: 'Error deleting file' message: 'Error deleting file',
}); });
} }
}); });
module.exports = router; module.exports = router;