forked from UKSOURCE/cms.hailearning.edu.vn
feat: implement comprehensive audit logging system
This commit is contained in:
@@ -2,16 +2,20 @@ const Travel = require("../models/travel");
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
const diffObject = require("../audit/diffObject");
|
||||
const AUDIT_ACTIONS = require("../constants/auditAction");
|
||||
|
||||
/**
|
||||
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
||||
*/
|
||||
function extractYouTubeId(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||
const regex = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
if (!url || typeof url !== "string") return null;
|
||||
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||
const regex =
|
||||
/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,214 +23,268 @@ function extractYouTubeId(url) {
|
||||
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
||||
*/
|
||||
function sanitizeContentBlocks(blocks) {
|
||||
if (!blocks || !Array.isArray(blocks)) return [];
|
||||
if (!blocks || !Array.isArray(blocks)) return [];
|
||||
|
||||
const seenVideoIds = new Set();
|
||||
|
||||
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||
const processedBlocks = blocks.map(block => {
|
||||
if (block.type === 'embed') {
|
||||
const url = block.data.source || block.data.embed || '';
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (videoId) {
|
||||
seenVideoIds.add(videoId);
|
||||
// Cập nhật lại data chuẩn cho Editor.js
|
||||
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
block.data.source = url;
|
||||
block.data.videoId = videoId;
|
||||
block.data.service = 'youtube';
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
const seenVideoIds = new Set();
|
||||
|
||||
// Bước 2: Lọc bỏ paragraph rác
|
||||
return processedBlocks.filter(block => {
|
||||
if (block.type === 'paragraph') {
|
||||
const text = (block.data?.text || '').trim();
|
||||
|
||||
// Xóa paragraph rỗng
|
||||
if (text === '' || text === '<br>' || text === ' ') return false;
|
||||
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||
const processedBlocks = blocks.map((block) => {
|
||||
if (block.type === "embed") {
|
||||
const url = block.data.source || block.data.embed || "";
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (videoId) {
|
||||
seenVideoIds.add(videoId);
|
||||
// Cập nhật lại data chuẩn cho Editor.js
|
||||
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||
block.data.source = url;
|
||||
block.data.videoId = videoId;
|
||||
block.data.service = "youtube";
|
||||
}
|
||||
}
|
||||
return block;
|
||||
});
|
||||
|
||||
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||
const videoIdInText = extractYouTubeId(text);
|
||||
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||
console.log(`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Bước 2: Lọc bỏ paragraph rác
|
||||
return processedBlocks.filter((block) => {
|
||||
if (block.type === "paragraph") {
|
||||
const text = (block.data?.text || "").trim();
|
||||
|
||||
// Xóa paragraph rỗng
|
||||
if (text === "" || text === "<br>" || text === " ") return false;
|
||||
|
||||
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||
const videoIdInText = extractYouTubeId(text);
|
||||
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||
console.log(
|
||||
`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// GET: Show travel editor
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
|
||||
if (!travel) {
|
||||
return res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: {
|
||||
page: { title: "Travel Information", description: "", metadata: { title: "", description: "" } },
|
||||
hero: { title: "Travel Information", backgroundImage: "" },
|
||||
content: { blocks: [] },
|
||||
enableScrollspy: false,
|
||||
},
|
||||
message: "No travel data found. Please run migration first.",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: travel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).send("Error loading travel page");
|
||||
if (!travel) {
|
||||
return res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: {
|
||||
page: {
|
||||
title: "Travel Information",
|
||||
description: "",
|
||||
metadata: { title: "", description: "" },
|
||||
},
|
||||
hero: { title: "Travel Information", backgroundImage: "" },
|
||||
content: { blocks: [] },
|
||||
enableScrollspy: false,
|
||||
},
|
||||
message: "No travel data found. Please run migration first.",
|
||||
});
|
||||
}
|
||||
|
||||
res.render("admin/travel/index", {
|
||||
title: "Travel Management",
|
||||
data: travel,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading travel page:", error);
|
||||
res.status(500).send("Error loading travel page");
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Update travel information
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
const { page, hero, content, enableScrollspy } = req.body;
|
||||
const updateData = {};
|
||||
try {
|
||||
const { page, hero, content, enableScrollspy } = req.body;
|
||||
|
||||
if (page) updateData.page = JSON.parse(page);
|
||||
if (hero) updateData.hero = JSON.parse(hero);
|
||||
|
||||
if (content) {
|
||||
let contentObj = JSON.parse(content);
|
||||
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
updateData.content = contentObj;
|
||||
}
|
||||
// Get current data for before state
|
||||
const currentTravel = await Travel.findOne();
|
||||
|
||||
if (enableScrollspy !== undefined) {
|
||||
updateData.enableScrollspy = (enableScrollspy === "true" || enableScrollspy === true);
|
||||
}
|
||||
// ✅ Capture BEFORE state
|
||||
const beforeData = currentTravel
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
currentTravel.toObject ? currentTravel.toObject() : currentTravel,
|
||||
),
|
||||
)
|
||||
: {};
|
||||
|
||||
await Travel.findOneAndUpdate({}, updateData, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
const updateData = {};
|
||||
|
||||
req.flash("success", "Travel information updated and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error updating travel:", error);
|
||||
req.flash("error", "Error updating travel information");
|
||||
res.redirect("/admin/travel");
|
||||
if (page) updateData.page = JSON.parse(page);
|
||||
if (hero) updateData.hero = JSON.parse(hero);
|
||||
|
||||
if (content) {
|
||||
let contentObj = JSON.parse(content);
|
||||
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
updateData.content = contentObj;
|
||||
}
|
||||
|
||||
if (enableScrollspy !== undefined) {
|
||||
updateData.enableScrollspy =
|
||||
enableScrollspy === "true" || enableScrollspy === true;
|
||||
}
|
||||
|
||||
const updatedTravel = await Travel.findOneAndUpdate({}, updateData, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
});
|
||||
|
||||
// ✅ Capture AFTER state
|
||||
const afterData = JSON.parse(
|
||||
JSON.stringify(
|
||||
updatedTravel.toObject ? updatedTravel.toObject() : updatedTravel,
|
||||
),
|
||||
);
|
||||
|
||||
// ✅ AUDIT LOGGING - Travel Updated
|
||||
const changes = diffObject(beforeData, afterData);
|
||||
if (changes.length > 0) {
|
||||
await writeAuditLog({
|
||||
model: "Travel",
|
||||
documentId: updatedTravel._id,
|
||||
action: AUDIT_ACTIONS.UPDATE_TRAVEL,
|
||||
before: beforeData,
|
||||
after: afterData,
|
||||
changes,
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
req.flash(
|
||||
"success",
|
||||
"Travel information updated and sanitized successfully",
|
||||
);
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error updating travel:", error);
|
||||
req.flash("error", "Error updating travel information");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
||||
exports.api = exports.getTravelData = async (req, res) => {
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
if (!travel) {
|
||||
return res.status(404).json({ error: "Travel data not found" });
|
||||
}
|
||||
|
||||
const travelObj = travel.toObject();
|
||||
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processed.hero,
|
||||
page: processed.page,
|
||||
content: processed.content,
|
||||
enableScrollspy: processed.enableScrollspy
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching travel data:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
try {
|
||||
const travel = await Travel.findOne();
|
||||
if (!travel) {
|
||||
return res.status(404).json({ error: "Travel data not found" });
|
||||
}
|
||||
|
||||
const travelObj = travel.toObject();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hero: processed.hero,
|
||||
page: processed.page,
|
||||
content: processed.content,
|
||||
enableScrollspy: processed.enableScrollspy,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching travel data:", error);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
};
|
||||
|
||||
// POST: Preview travel
|
||||
exports.preview = async (req, res) => {
|
||||
try {
|
||||
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } = req.body;
|
||||
try {
|
||||
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } =
|
||||
req.body;
|
||||
|
||||
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||
let contentObj = JSON.parse(content);
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||
let contentObj = JSON.parse(content);
|
||||
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||
|
||||
const previewData = {
|
||||
page: {
|
||||
title: pageTitle || "Travel Information",
|
||||
year: pageYear || ""
|
||||
},
|
||||
hero: {
|
||||
title: heroTitle || "Travel Information",
|
||||
backgroundImage: heroBackgroundImage || "",
|
||||
},
|
||||
content: contentObj,
|
||||
enableScrollspy: false,
|
||||
};
|
||||
const previewData = {
|
||||
page: {
|
||||
title: pageTitle || "Travel Information",
|
||||
year: pageYear || "",
|
||||
},
|
||||
hero: {
|
||||
title: heroTitle || "Travel Information",
|
||||
backgroundImage: heroBackgroundImage || "",
|
||||
},
|
||||
content: contentObj,
|
||||
enableScrollspy: false,
|
||||
};
|
||||
|
||||
res.render("page/travel", {
|
||||
title: "Travel Preview",
|
||||
data: previewData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
res.render("page/travel", {
|
||||
title: "Travel Preview",
|
||||
data: previewData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating preview:", error);
|
||||
res.status(500).send("Error generating preview");
|
||||
}
|
||||
};
|
||||
|
||||
// GET: Seed/Import from JSON
|
||||
exports.seed = async (req, res) => {
|
||||
try {
|
||||
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||
const jsonTravelData = JSON.parse(jsonData);
|
||||
try {
|
||||
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||
const jsonTravelData = JSON.parse(jsonData);
|
||||
|
||||
let contentBlocks = [];
|
||||
let contentBlocks = [];
|
||||
|
||||
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||
if (Array.isArray(jsonTravelData.posts) && jsonTravelData.posts.length > 0) {
|
||||
const firstPost = jsonTravelData.posts[0];
|
||||
contentBlocks = (firstPost.content && firstPost.content.blocks) ? firstPost.content.blocks : [];
|
||||
}
|
||||
// Trường hợp format cũ (legacy)
|
||||
else {
|
||||
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||
}
|
||||
|
||||
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||
|
||||
const travelData = {
|
||||
page: {
|
||||
title: jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||
year: jsonTravelData.page?.year || "",
|
||||
metadata: {
|
||||
title: "Travel Guide - Go and Grow Camp",
|
||||
description: "Everything you need to know about traveling to our camps",
|
||||
},
|
||||
},
|
||||
hero: {
|
||||
title: jsonTravelData.hero?.title || "Travel Information",
|
||||
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||
},
|
||||
content: { blocks: cleanedBlocks },
|
||||
enableScrollspy: true,
|
||||
};
|
||||
|
||||
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||
|
||||
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error seeding travel data:", error);
|
||||
req.flash("error", "Failed to seed travel data");
|
||||
res.redirect("/admin/travel");
|
||||
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||
if (
|
||||
Array.isArray(jsonTravelData.posts) &&
|
||||
jsonTravelData.posts.length > 0
|
||||
) {
|
||||
const firstPost = jsonTravelData.posts[0];
|
||||
contentBlocks =
|
||||
firstPost.content && firstPost.content.blocks
|
||||
? firstPost.content.blocks
|
||||
: [];
|
||||
}
|
||||
};
|
||||
// Trường hợp format cũ (legacy)
|
||||
else {
|
||||
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||
}
|
||||
|
||||
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||
|
||||
const travelData = {
|
||||
page: {
|
||||
title:
|
||||
jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||
year: jsonTravelData.page?.year || "",
|
||||
metadata: {
|
||||
title: "Travel Guide - Go and Grow Camp",
|
||||
description:
|
||||
"Everything you need to know about traveling to our camps",
|
||||
},
|
||||
},
|
||||
hero: {
|
||||
title: jsonTravelData.hero?.title || "Travel Information",
|
||||
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||
},
|
||||
content: { blocks: cleanedBlocks },
|
||||
enableScrollspy: true,
|
||||
};
|
||||
|
||||
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||
|
||||
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||
res.redirect("/admin/travel");
|
||||
} catch (error) {
|
||||
console.error("Error seeding travel data:", error);
|
||||
req.flash("error", "Failed to seed travel data");
|
||||
res.redirect("/admin/travel");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user