forked from UKSOURCE/cms.hailearning.edu.vn
Add CMS support for floating contact widget with Facebook/Zalo quick actions. Includes mongoose schema, admin UI tab, image upload with sharp resize presets, deferred form submission with draft persistence, and upload middleware error handling.
320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
const path = require('path');
|
|
const fs = require('fs');
|
|
const sharp = require('sharp');
|
|
const jsonHelper = require('../utils/jsonHelper');
|
|
|
|
const imageResizePresets = {
|
|
floatingContactBrandImage: { width: 104, height: 104, quality: 88 },
|
|
floatingContactTriggerIcon: { width: 96, height: 96, quality: 88 },
|
|
floatingContactActionIcon: { width: 84, height: 84, quality: 88 },
|
|
};
|
|
|
|
const isSvgFile = (filePath) => path.extname(filePath).toLowerCase() === '.svg';
|
|
|
|
function scheduleTemporaryFileCleanup(filePath, attemptsLeft = 5, delayMs = 250) {
|
|
if (!filePath || attemptsLeft <= 0) {
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
} catch (cleanupError) {
|
|
scheduleTemporaryFileCleanup(filePath, attemptsLeft - 1, delayMs * 2);
|
|
}
|
|
}, delayMs);
|
|
}
|
|
|
|
function getFinalUploadTarget(file, req, forceWebp = false) {
|
|
const parsedPath = path.parse(file.path);
|
|
const requestedFileName =
|
|
req.uploadFinalFileName ||
|
|
file.filename.replace('.__upload__', '');
|
|
const parsedRequestedFileName = path.parse(requestedFileName);
|
|
const finalFileName = forceWebp
|
|
? `${parsedRequestedFileName.name}.webp`
|
|
: requestedFileName;
|
|
const finalDirectory = req.uploadFinalDirectory || parsedPath.dir;
|
|
|
|
return {
|
|
finalFileName,
|
|
finalPath: path.join(finalDirectory, finalFileName),
|
|
};
|
|
}
|
|
|
|
async function finalizeUploadedImage(file, req, resizePreset) {
|
|
const preset = imageResizePresets[resizePreset];
|
|
|
|
if (!file) {
|
|
return file;
|
|
}
|
|
|
|
if (!preset || isSvgFile(file.path)) {
|
|
const { finalFileName, finalPath } = getFinalUploadTarget(file, req);
|
|
|
|
if (path.resolve(file.path) !== path.resolve(finalPath)) {
|
|
fs.renameSync(file.path, finalPath);
|
|
}
|
|
|
|
return {
|
|
...file,
|
|
filename: finalFileName,
|
|
path: finalPath,
|
|
};
|
|
}
|
|
|
|
const { finalFileName, finalPath } = getFinalUploadTarget(file, req, true);
|
|
|
|
await sharp(file.path)
|
|
.resize(preset.width, preset.height, {
|
|
fit: 'contain',
|
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: preset.quality })
|
|
.toFile(finalPath);
|
|
|
|
if (fs.existsSync(file.path)) {
|
|
try {
|
|
fs.unlinkSync(file.path);
|
|
} catch (cleanupError) {
|
|
console.warn('Unable to remove original uploaded image after optimization:', cleanupError.message);
|
|
scheduleTemporaryFileCleanup(file.path);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...file,
|
|
filename: finalFileName,
|
|
path: finalPath,
|
|
mimetype: 'image/webp',
|
|
};
|
|
}
|
|
|
|
// Controller xử lý upload ảnh
|
|
const uploadController = {
|
|
// Upload ảnh và trả về đường dẫn
|
|
uploadImage: async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
|
}
|
|
|
|
// Lấy loại ảnh từ query params
|
|
const imageType = req.query.imageType || 'general';
|
|
const resizePreset = req.query.resizePreset || '';
|
|
req.file = await finalizeUploadedImage(req.file, req, resizePreset);
|
|
|
|
// Tạo đường dẫn tương đối để lưu vào database
|
|
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
|
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
|
const fullUrl = `${baseUrl}${relativePath}`;
|
|
|
|
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
|
|
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
|
|
try {
|
|
const jsonFile = req.body && req.body.jsonFile;
|
|
const campLink = req.body && req.body.campLink;
|
|
|
|
if (jsonFile && campLink) {
|
|
// Đọc JSON và cập nhật camp có link khớp
|
|
const jsonFilePath = require('path').join(__dirname, '../data', jsonFile);
|
|
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
|
|
|
if (jsonData && Array.isArray(jsonData.camps)) {
|
|
// campLink có thể được gửi không có dấu / đầu, chuẩn hóa
|
|
const normalizedLink = campLink.startsWith('/') ? campLink : `/${campLink}`;
|
|
|
|
const camp = jsonData.camps.find(c => (c.link || '').toString() === normalizedLink || (c.link || '').toString() === campLink);
|
|
if (camp) {
|
|
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
|
|
camp.image = relativePath;
|
|
|
|
// Đảm bảo camp-detail.hero tồn tại và sync bgImage
|
|
if (!camp['camp-detail']) camp['camp-detail'] = {};
|
|
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
|
|
camp['camp-detail'].hero.bgImage = relativePath;
|
|
|
|
// Lưu thay đổi
|
|
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to update JSON file after upload:', e && e.message ? e.message : e);
|
|
}
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
path: relativePath,
|
|
url: fullUrl,
|
|
resizePreset: resizePreset || null,
|
|
reused: false,
|
|
message: 'File uploaded successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error uploading image:', error);
|
|
return res.status(500).json({ success: false, error: 'Server error while uploading image' });
|
|
}
|
|
},
|
|
|
|
// Cập nhật đường dẫn ảnh trong file JSON
|
|
updateImagePath: async (req, res) => {
|
|
try {
|
|
const { jsonFile, jsonPath, newImagePath } = req.body;
|
|
|
|
if (!jsonFile || !jsonPath || !newImagePath) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
|
|
});
|
|
}
|
|
|
|
// Đọc file JSON
|
|
const jsonFilePath = path.join(__dirname, '../data', jsonFile);
|
|
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
|
|
|
// Cập nhật đường dẫn ảnh theo jsonPath
|
|
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
|
|
const pathParts = jsonPath.split('.');
|
|
let current = jsonData;
|
|
|
|
// Duyệt qua các phần của path trừ phần cuối
|
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
const part = pathParts[i];
|
|
|
|
// Kiểm tra nếu là mảng (ví dụ: partners[0])
|
|
if (part.includes('[') && part.includes(']')) {
|
|
const arrName = part.substring(0, part.indexOf('['));
|
|
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
|
|
|
|
if (!current[arrName] || !Array.isArray(current[arrName])) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Array ${arrName} not found in data`
|
|
});
|
|
}
|
|
|
|
current = current[arrName][index];
|
|
} else {
|
|
if (!current[part]) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Property ${part} not found in data`
|
|
});
|
|
}
|
|
|
|
current = current[part];
|
|
}
|
|
}
|
|
|
|
// Cập nhật giá trị
|
|
const lastPart = pathParts[pathParts.length - 1];
|
|
current[lastPart] = newImagePath;
|
|
|
|
// Lưu lại file JSON
|
|
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
message: 'Image path updated successfully',
|
|
data: { jsonPath, newImagePath }
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating image path:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error while updating image path' });
|
|
}
|
|
},
|
|
|
|
// Xóa ảnh
|
|
deleteImage: async (req, res) => {
|
|
try {
|
|
const { imagePath } = req.body;
|
|
|
|
if (!imagePath) {
|
|
return res.status(400).json({ success: false, message: 'Missing image path to delete' });
|
|
}
|
|
|
|
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
|
|
const fullPath = path.join(__dirname, '../public', imagePath);
|
|
|
|
// Kiểm tra xem file có tồn tại không
|
|
if (!fs.existsSync(fullPath)) {
|
|
return res.status(404).json({ success: false, message: 'Image file not found' });
|
|
}
|
|
|
|
// Xóa file
|
|
fs.unlinkSync(fullPath);
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
message: 'Image deleted successfully',
|
|
data: { imagePath }
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting image:', error);
|
|
return res.status(500).json({ success: false, message: 'Server error while deleting image' });
|
|
}
|
|
},
|
|
|
|
// List images in a folder
|
|
listImages: async (req, res) => {
|
|
try {
|
|
const imageType = req.query.imageType || 'general';
|
|
const dirPath = path.join(__dirname, '../public/uploads', imageType);
|
|
|
|
if (!fs.existsSync(dirPath)) {
|
|
return res.status(200).json({ success: true, images: [] });
|
|
}
|
|
|
|
const files = fs.readdirSync(dirPath).filter(f => !f.startsWith('.'));
|
|
|
|
const images = files.map(name => ({
|
|
name,
|
|
path: `/uploads/${imageType}/${name}`,
|
|
url: (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`) + `/uploads/${imageType}/${name}`
|
|
}));
|
|
|
|
return res.status(200).json({ success: true, images });
|
|
} catch (error) {
|
|
console.error('Error listing images:', error);
|
|
return res.status(500).json({ success: false, error: 'Server error while listing images' });
|
|
}
|
|
},
|
|
|
|
// Upload video
|
|
uploadVideo: async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
|
}
|
|
|
|
// Kiểm tra loại file
|
|
const fileType = req.file.mimetype;
|
|
if (!fileType.startsWith('video/')) {
|
|
// Xóa file nếu không phải video
|
|
fs.unlinkSync(req.file.path);
|
|
return res.status(400).json({ success: false, error: 'Uploaded file is not a video' });
|
|
}
|
|
|
|
// Tạo đường dẫn tương đối để lưu vào database
|
|
const relativePath = `/uploads/videos/${req.file.filename}`;
|
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
|
const fullUrl = `${baseUrl}${relativePath}`;
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
path: relativePath,
|
|
url: fullUrl
|
|
});
|
|
} catch (error) {
|
|
console.error('Error uploading video:', error);
|
|
return res.status(500).json({ success: false, error: 'Server error while uploading video' });
|
|
}
|
|
}
|
|
};
|
|
|
|
module.exports = uploadController;
|