This commit is contained in:
2026-07-20 14:46:07 +07:00
parent 476c41cf08
commit 4a159cad71
10 changed files with 2224 additions and 3 deletions

View File

@@ -8,6 +8,7 @@ const path = require('path');
const express = require('express');
const multer = require('multer');
const repository = require('./src/repository');
const notificationRepository = require('./src/notification-repository');
const mailer = require('./src/mailer');
const { closePool, getPool } = require('./src/db');
const notiflixVersion = require('notiflix/package.json').version;
@@ -123,6 +124,7 @@ app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '32kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
app.use(applyPublicApiCors);
@@ -524,6 +526,55 @@ function sanitizeReturnTo(value) {
return value;
}
function sanitizeNotificationActionUrl(value) {
const text = String(value || '').trim();
if (!text) return '';
if (
text.length > 1000
|| !text.startsWith('/')
|| text.startsWith('//')
|| text.includes('\\')
|| /[\u0000-\u001F\u007F]/.test(text)
) {
return null;
}
return text;
}
function normalizeNotificationSeverity(value) {
const severity = String(value || 'info').trim().toLowerCase();
return ['info', 'success', 'warning', 'error'].includes(severity) ? severity : null;
}
function isUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
String(value || '').trim()
);
}
function getNotificationActorName(user) {
return String(user?.name || user?.username || '').trim();
}
async function safelyCreateNotification(label, callback) {
try {
return await callback();
} catch (error) {
console.error(`Cannot create notification (${label}):`, error);
return null;
}
}
async function safelyLoadNotificationContext(label, callback) {
try {
return await callback();
} catch (error) {
console.error(`Cannot load notification context (${label}):`, error);
return null;
}
}
function getBaseUrl(req) {
const requestBaseUrl = getRequestBaseUrl(req);
if (process.env.APP_BASE_URL) {
@@ -851,6 +902,15 @@ function requireAdmin(req, res, next) {
redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.');
}
function requireAdminApi(req, res, next) {
if (req.currentUser && req.currentUser.role === 'Admin') {
next();
return;
}
res.status(403).json({ error: 'Bạn cần quyền Admin để thực hiện thao tác này.' });
}
function normalizePackageType(value) {
const packageType = String(value || 'deb').toLowerCase();
return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb';
@@ -1497,6 +1557,92 @@ exit 1
app.use(requireAuthenticated);
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.get('/api/notifications', asyncRoute(async (req, res) => {
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
const result = countOnly
? await notificationRepository.getUnreadCount(req.currentUser.id)
: await notificationRepository.listNotifications(req.currentUser.id, {
limit: req.query.limit,
unreadOnly: String(req.query.unreadOnly || '').toLowerCase() === 'true'
});
res.setHeader('Cache-Control', 'no-store');
res.json(result);
}));
app.post('/api/notifications/read-all', asyncRoute(async (req, res) => {
const result = await notificationRepository.markAllNotificationsRead(req.currentUser.id);
if (!result.available) {
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
return;
}
res.json(result);
}));
app.post('/api/notifications/:id/read', asyncRoute(async (req, res) => {
if (!isUuid(req.params.id)) {
res.status(400).json({ error: 'Notification id không hợp lệ.' });
return;
}
const result = await notificationRepository.markNotificationRead(req.currentUser.id, req.params.id);
if (!result.available) {
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
return;
}
if (!result.updated) {
res.status(404).json({ error: 'Không tìm thấy thông báo.' });
return;
}
res.json(result);
}));
app.post('/api/notifications/broadcasts', requireAdminApi, asyncRoute(async (req, res) => {
const title = String(req.body.title || '').trim();
const message = String(req.body.message || '').trim();
const severity = normalizeNotificationSeverity(req.body.severity);
const actionUrl = sanitizeNotificationActionUrl(req.body.actionUrl);
if (!title || title.length > 200) {
res.status(400).json({ error: 'Tiêu đề bắt buộc và không được vượt quá 200 ký tự.' });
return;
}
if (!message || message.length > 1000) {
res.status(400).json({ error: 'Nội dung bắt buộc và không được vượt quá 1000 ký tự.' });
return;
}
if (!severity) {
res.status(400).json({ error: 'Mức độ thông báo không hợp lệ.' });
return;
}
if (actionUrl === null) {
res.status(400).json({ error: 'Đường dẫn phải là đường dẫn nội bộ bắt đầu bằng /.' });
return;
}
const result = await notificationRepository.createSystemBroadcast({
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
severity,
title,
message,
actionUrl
});
if (!result.available) {
res.status(503).json({ error: 'Chưa có bảng dbo.Notifications. Hãy chạy migration 04_notifications.sql trước.' });
return;
}
res.status(201).json(result);
}));
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
const pageData = await repository.getPageData(req.currentUser);
const agentPackages = await listAgentPackages();
@@ -1824,6 +1970,14 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
};
await repository.addPackageVersion(versionInput);
await safelyCreateNotification('package version available', () => (
notificationRepository.notifyPackageVersionAvailable({
packageId: versionInput.packageId,
version: versionInput.version,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
} catch (error) {
@@ -1833,6 +1987,15 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
if (replacedVersionId) {
await safelyCreateNotification('package version artifact updated', () => (
notificationRepository.notifyPackageVersionAvailable({
packageId: versionInput.packageId,
version: versionInput.version,
isArtifactUpdate: true,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
redirectWithNotice(
res,
`/packages/${versionInput.packageId}`,
@@ -1857,8 +2020,28 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
}));
app.post('/packages/:id/delete', asyncRoute(async (req, res) => {
const notificationImpact = await safelyLoadNotificationContext('package delete', () => (
notificationRepository.getPackageImpact(req.params.id)
));
const deleted = await repository.deletePackage(req.params.id);
if (deleted && notificationImpact) {
await safelyCreateNotification('package deleted', () => (
notificationRepository.createNotificationsForUsers({
recipientUserIds: notificationImpact.recipientUserIds,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
eventType: 'package.deleted',
severity: 'error',
title: `Package ${notificationImpact.packageCode} đã bị xóa`,
message: 'Các liên kết application sử dụng package này đã bị gỡ. Hãy kiểm tra lại cấu hình application liên quan.',
actionUrl: '/applications',
entityType: 'package',
entityId: notificationImpact.packageId
})
));
}
redirectWithNotice(
res,
'/packages',
@@ -1876,8 +2059,28 @@ app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => {
app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages');
const notificationImpact = await safelyLoadNotificationContext('package version delete', () => (
notificationRepository.getPackageVersionImpact(req.params.id)
));
const result = await repository.deletePackageVersion(req.params.id);
if (result.deleted && notificationImpact) {
await safelyCreateNotification('package version deleted', () => (
notificationRepository.createNotificationsForUsers({
recipientUserIds: notificationImpact.recipientUserIds,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
eventType: 'package.version_deleted',
severity: 'warning',
title: `Version ${notificationImpact.version} của ${notificationImpact.packageCode} đã bị xóa`,
message: 'Các liên kết application sử dụng chính version này đã bị gỡ. Hãy kiểm tra lại application liên quan.',
actionUrl: '/applications',
entityType: 'package',
entityId: notificationImpact.packageId
})
));
}
redirectWithNotice(
res,
result.packageId ? `/packages/${result.packageId}` : returnTo,
@@ -2037,6 +2240,16 @@ app.post('/applications/:id/release', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`);
const updated = await repository.updateApplicationStatus(applicationId, 'Released');
if (updated) {
await safelyCreateNotification('application released', () => (
notificationRepository.notifyApplicationReleased({
applicationId,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
}
redirectWithNotice(
res,
returnTo,
@@ -2237,6 +2450,13 @@ app.get('/builder', asyncRoute(async (req, res) => {
app.use(async (error, req, res, next) => {
console.error(error);
if (req.path.startsWith('/api/notifications')) {
res.status(500).json({
error: 'Không thể xử lý thông báo lúc này. Vui lòng thử lại sau.'
});
return;
}
const pageData = await repository.getPageData(req.currentUser).catch(() => ({
currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' },
stats: {