415 lines
11 KiB
JavaScript
415 lines
11 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const multer = require('multer');
|
|
const sharp = require('sharp');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
|
|
// Authentication middleware
|
|
const requireAuth = (req, res, next) => {
|
|
if (!req.session.user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Authentication required'
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Configure multer for file uploads
|
|
const storage = multer.diskStorage({
|
|
destination: async (req, file, cb) => {
|
|
const uploadPath = path.join(__dirname, '../public/uploads');
|
|
try {
|
|
await fs.mkdir(uploadPath, { recursive: true });
|
|
cb(null, uploadPath);
|
|
} catch (error) {
|
|
cb(error);
|
|
}
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: {
|
|
fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024, // 10MB
|
|
files: 10
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Allow images only
|
|
if (file.mimetype.startsWith('image/')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only image files are allowed'), false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Upload single image
|
|
router.post('/upload', requireAuth, upload.single('image'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'No file uploaded'
|
|
});
|
|
}
|
|
|
|
const originalPath = req.file.path;
|
|
const filename = req.file.filename;
|
|
const nameWithoutExt = path.parse(filename).name;
|
|
|
|
// Create optimized versions
|
|
const sizes = {
|
|
thumbnail: { width: 300, height: 200 },
|
|
medium: { width: 800, height: 600 },
|
|
large: { width: 1200, height: 900 }
|
|
};
|
|
|
|
const optimizedImages = {};
|
|
|
|
for (const [sizeName, dimensions] of Object.entries(sizes)) {
|
|
const outputPath = path.join(
|
|
path.dirname(originalPath),
|
|
`${nameWithoutExt}-${sizeName}.webp`
|
|
);
|
|
|
|
await sharp(originalPath)
|
|
.resize(dimensions.width, dimensions.height, {
|
|
fit: 'inside',
|
|
withoutEnlargement: true
|
|
})
|
|
.webp({ quality: 85 })
|
|
.toFile(outputPath);
|
|
|
|
optimizedImages[sizeName] = `/uploads/${path.basename(outputPath)}`;
|
|
}
|
|
|
|
// Keep original as well (converted to webp for better compression)
|
|
const originalWebpPath = path.join(
|
|
path.dirname(originalPath),
|
|
`${nameWithoutExt}-original.webp`
|
|
);
|
|
|
|
await sharp(originalPath)
|
|
.webp({ quality: 90 })
|
|
.toFile(originalWebpPath);
|
|
|
|
optimizedImages.original = `/uploads/${path.basename(originalWebpPath)}`;
|
|
|
|
// Remove the original uploaded file
|
|
await fs.unlink(originalPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Image uploaded and optimized successfully',
|
|
images: optimizedImages,
|
|
metadata: {
|
|
originalName: req.file.originalname,
|
|
mimeType: req.file.mimetype,
|
|
size: req.file.size
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Image upload error:', error);
|
|
|
|
// Clean up files on error
|
|
if (req.file && req.file.path) {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
} catch (unlinkError) {
|
|
console.error('Error removing file:', unlinkError);
|
|
}
|
|
}
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Error uploading image'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Upload multiple images
|
|
router.post('/upload-multiple', requireAuth, upload.array('images', 10), async (req, res) => {
|
|
try {
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'No files uploaded'
|
|
});
|
|
}
|
|
|
|
const uploadedImages = [];
|
|
|
|
for (const file of req.files) {
|
|
try {
|
|
const originalPath = file.path;
|
|
const filename = file.filename;
|
|
const nameWithoutExt = path.parse(filename).name;
|
|
|
|
// Create optimized versions
|
|
const sizes = {
|
|
thumbnail: { width: 300, height: 200 },
|
|
medium: { width: 800, height: 600 },
|
|
large: { width: 1200, height: 900 }
|
|
};
|
|
|
|
const optimizedImages = {};
|
|
|
|
for (const [sizeName, dimensions] of Object.entries(sizes)) {
|
|
const outputPath = path.join(
|
|
path.dirname(originalPath),
|
|
`${nameWithoutExt}-${sizeName}.webp`
|
|
);
|
|
|
|
await sharp(originalPath)
|
|
.resize(dimensions.width, dimensions.height, {
|
|
fit: 'inside',
|
|
withoutEnlargement: true
|
|
})
|
|
.webp({ quality: 85 })
|
|
.toFile(outputPath);
|
|
|
|
optimizedImages[sizeName] = `/uploads/${path.basename(outputPath)}`;
|
|
}
|
|
|
|
// Original as webp
|
|
const originalWebpPath = path.join(
|
|
path.dirname(originalPath),
|
|
`${nameWithoutExt}-original.webp`
|
|
);
|
|
|
|
await sharp(originalPath)
|
|
.webp({ quality: 90 })
|
|
.toFile(originalWebpPath);
|
|
|
|
optimizedImages.original = `/uploads/${path.basename(originalWebpPath)}`;
|
|
|
|
uploadedImages.push({
|
|
originalName: file.originalname,
|
|
images: optimizedImages,
|
|
metadata: {
|
|
mimeType: file.mimetype,
|
|
size: file.size
|
|
}
|
|
});
|
|
|
|
// Remove original file
|
|
await fs.unlink(originalPath);
|
|
} catch (fileError) {
|
|
console.error(`Error processing file ${file.originalname}:`, fileError);
|
|
// Clean up this file and continue with others
|
|
try {
|
|
await fs.unlink(file.path);
|
|
} catch (unlinkError) {
|
|
console.error('Error removing file:', unlinkError);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `${uploadedImages.length} images uploaded and optimized successfully`,
|
|
images: uploadedImages
|
|
});
|
|
} catch (error) {
|
|
console.error('Multiple images upload error:', error);
|
|
|
|
// Clean up files on error
|
|
if (req.files) {
|
|
for (const file of req.files) {
|
|
try {
|
|
await fs.unlink(file.path);
|
|
} catch (unlinkError) {
|
|
console.error('Error removing file:', unlinkError);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Error uploading images'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete image
|
|
router.delete('/:filename', requireAuth, async (req, res) => {
|
|
try {
|
|
const filename = req.params.filename;
|
|
const uploadPath = path.join(__dirname, '../public/uploads');
|
|
|
|
// Security check - ensure filename doesn't contain path traversal
|
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Invalid filename'
|
|
});
|
|
}
|
|
|
|
const filePath = path.join(uploadPath, filename);
|
|
|
|
try {
|
|
await fs.access(filePath);
|
|
await fs.unlink(filePath);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Image deleted successfully'
|
|
});
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: 'Image not found'
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
console.error('Image deletion error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Error deleting image'
|
|
});
|
|
}
|
|
});
|
|
|
|
// List uploaded images
|
|
router.get('/list', requireAuth, async (req, res) => {
|
|
try {
|
|
const uploadPath = path.join(__dirname, '../public/uploads');
|
|
|
|
// Create uploads directory if it doesn't exist
|
|
try {
|
|
await fs.mkdir(uploadPath, { recursive: true });
|
|
} catch (mkdirError) {
|
|
// Directory might already exist, continue
|
|
}
|
|
|
|
const page = parseInt(req.query.page) || 1;
|
|
const limit = parseInt(req.query.limit) || 50;
|
|
|
|
let files;
|
|
try {
|
|
files = await fs.readdir(uploadPath);
|
|
} catch (readdirError) {
|
|
if (readdirError.code === 'ENOENT') {
|
|
// Directory doesn't exist, return empty list
|
|
return res.json({
|
|
success: true,
|
|
images: [],
|
|
pagination: {
|
|
current: 1,
|
|
total: 0,
|
|
limit,
|
|
totalItems: 0,
|
|
hasNext: false,
|
|
hasPrev: false
|
|
}
|
|
});
|
|
}
|
|
throw readdirError;
|
|
}
|
|
|
|
const imageFiles = files.filter(file =>
|
|
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)
|
|
);
|
|
|
|
// Sort files by modification time (newest first)
|
|
const filesWithStats = await Promise.all(
|
|
imageFiles.map(async (file) => {
|
|
try {
|
|
const filePath = path.join(uploadPath, file);
|
|
const stats = await fs.stat(filePath);
|
|
return { file, stats };
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const validFiles = filesWithStats
|
|
.filter(item => item !== null)
|
|
.sort((a, b) => b.stats.mtime - a.stats.mtime);
|
|
|
|
const total = validFiles.length;
|
|
const totalPages = Math.ceil(total / limit);
|
|
const start = (page - 1) * limit;
|
|
const end = start + limit;
|
|
|
|
const paginatedFiles = validFiles.slice(start, end);
|
|
|
|
const imagesWithDetails = await Promise.all(
|
|
paginatedFiles.map(async ({ file, stats }) => {
|
|
try {
|
|
const filePath = path.join(uploadPath, file);
|
|
|
|
// Try to get image dimensions
|
|
let dimensions = null;
|
|
try {
|
|
const metadata = await sharp(filePath).metadata();
|
|
dimensions = {
|
|
width: metadata.width,
|
|
height: metadata.height
|
|
};
|
|
} catch (sharpError) {
|
|
// Not a processable image, skip dimensions
|
|
}
|
|
|
|
// Determine mime type from extension
|
|
const ext = path.extname(file).toLowerCase();
|
|
let mimetype = 'image/jpeg';
|
|
switch (ext) {
|
|
case '.png': mimetype = 'image/png'; break;
|
|
case '.gif': mimetype = 'image/gif'; break;
|
|
case '.webp': mimetype = 'image/webp'; break;
|
|
case '.svg': mimetype = 'image/svg+xml'; break;
|
|
}
|
|
|
|
return {
|
|
filename: file,
|
|
url: `/uploads/${file}`,
|
|
size: stats.size,
|
|
mimetype,
|
|
uploadedAt: stats.birthtime || stats.mtime,
|
|
modifiedAt: stats.mtime,
|
|
dimensions,
|
|
isImage: true
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error getting details for ${file}:`, error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const validImages = imagesWithDetails.filter(img => img !== null);
|
|
|
|
res.json({
|
|
success: true,
|
|
images: validImages,
|
|
pagination: {
|
|
current: page,
|
|
total: totalPages,
|
|
limit,
|
|
totalItems: total,
|
|
hasNext: page < totalPages,
|
|
hasPrev: page > 1
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('List images error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Error listing images'
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = router; |