Cài đặt quota cho các thành viên
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
const Asset = require('../models/Asset');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Cấu hình Quota cho từng nhóm người dùng (đơn vị: Bytes)
|
||||||
|
const ROLE_QUOTAS = {
|
||||||
|
'Thành viên': 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
'editor': 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
|
'admin': 100 * 1024 * 1024 * 1024, // 100GB (hoặc Infinity)
|
||||||
|
'Chủ sở hữu': Infinity // Không giới hạn
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware kiểm tra giới hạn lưu trữ của người dùng
|
||||||
|
*/
|
||||||
|
const checkQuota = async (req, res, next) => {
|
||||||
|
if (!req.user) return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
|
||||||
|
const userRole = req.user.role || 'Thành viên';
|
||||||
|
const quota = ROLE_QUOTAS[userRole] || ROLE_QUOTAS['Thành viên'];
|
||||||
|
|
||||||
|
// Nếu không giới hạn thì đi tiếp
|
||||||
|
if (quota === Infinity) return next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sử dụng MongoDB Aggregation để tính tổng dung lượng ngay trên database
|
||||||
|
const usageResult = await Asset.aggregate([
|
||||||
|
{ $match: { uploadedBy: req.user._id } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0;
|
||||||
|
|
||||||
|
const newFileSize = req.file ? req.file.size : 0;
|
||||||
|
|
||||||
|
if (currentUsage + newFileSize > quota) {
|
||||||
|
// Xóa file tạm vừa upload lên nếu vượt định mức
|
||||||
|
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
message: `Vượt quá giới hạn lưu trữ. Định mức của bạn là ${(quota / (1024**3)).toFixed(1)}GB. Bạn đã sử dụng ${(currentUsage / (1024**3)).toFixed(2)}GB.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Quota Check Error]:', error);
|
||||||
|
next(); // Cho phép đi tiếp nếu lỗi logic kiểm tra để tránh chặn người dùng oan
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { checkQuota, ROLE_QUOTAS };
|
||||||
@@ -13,6 +13,7 @@ const Setting = require('../models/Setting');
|
|||||||
|
|
||||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||||
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
|
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
|
||||||
|
const { checkQuota, ROLE_QUOTAS } = require('../middlewares/quotaMiddleware');
|
||||||
const { resizeTo8K } = require('../utils/imageHelper');
|
const { resizeTo8K } = require('../utils/imageHelper');
|
||||||
const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper');
|
const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper');
|
||||||
const { imageQueue } = require('./imageQueue');
|
const { imageQueue } = require('./imageQueue');
|
||||||
@@ -78,7 +79,7 @@ const uploadSinglePanorama = (req, res, next) => {
|
|||||||
* @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection)
|
* @desc Create a new 3D scene (with 360 photo, 8K resize, EXIF injection)
|
||||||
* @access Private (Registered Users)
|
* @access Private (Registered Users)
|
||||||
*/
|
*/
|
||||||
router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
router.post('/scenes', protect, uploadSinglePanorama, checkQuota, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
const { title, lat, lng, privacy, sharedWithUsers } = req.body;
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
// 5. Save Asset to DB
|
// 5. Save Asset to DB
|
||||||
const asset = new Asset({
|
const asset = new Asset({
|
||||||
filePath: tempFilePath, // Tạm thời dùng file gốc cho đến khi worker xử lý xong
|
filePath: tempFilePath, // Tạm thời dùng file gốc cho đến khi worker xử lý xong
|
||||||
|
fileSize: req.file.size,
|
||||||
uploadedBy: req.user._id,
|
uploadedBy: req.user._id,
|
||||||
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude }
|
coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude }
|
||||||
});
|
});
|
||||||
@@ -740,8 +742,65 @@ router.delete('/scenes/:id', protect, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/me/profile', protect, async (req, res) => {
|
router.get('/me/profile', protect, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await User.findById(req.user._id).select('-password');
|
const user = await User.findById(req.user._id).select('-password').lean();
|
||||||
res.json(user);
|
|
||||||
|
// Tính toán dung lượng thực tế của người dùng
|
||||||
|
const usageResult = await Asset.aggregate([
|
||||||
|
{ $match: { uploadedBy: req.user._id } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
totalUsage: { $sum: { $ifNull: ["$fileSize", 0] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentUsage = usageResult.length > 0 ? usageResult[0].totalUsage : 0;
|
||||||
|
const quota = ROLE_QUOTAS[user.role] || ROLE_QUOTAS['Thành viên'];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...user,
|
||||||
|
storage: {
|
||||||
|
used: currentUsage,
|
||||||
|
quota: quota === Infinity ? -1 : quota // -1 đại diện cho không giới hạn
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/me/assets/top-large
|
||||||
|
* @desc Lấy danh sách 5 tệp tin chiếm dung lượng lớn nhất của người dùng
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get('/me/assets/top-large', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const topAssets = await Asset.aggregate([
|
||||||
|
{ $match: { uploadedBy: req.user._id } },
|
||||||
|
{ $sort: { fileSize: -1 } },
|
||||||
|
{ $limit: 5 },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'scenes',
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: 'assetId',
|
||||||
|
as: 'scene'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $unwind: { path: '$scene', preserveNullAndEmptyArrays: true } },
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
fileSize: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
'scene.name': 1,
|
||||||
|
'scene.title': 1,
|
||||||
|
'scene._id': 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
res.json(topAssets);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const connectDB = require('../config/db');
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script cập nhật bổ sung trường fileSize cho các Asset cũ
|
||||||
|
* Giúp tối ưu hóa việc kiểm tra Quota và thống kê dung lượng
|
||||||
|
*/
|
||||||
|
const fixAssetFileSize = async () => {
|
||||||
|
try {
|
||||||
|
console.log('=== BẮT ĐẦU CẬP NHẬT KÍCH THƯỚC FILE CHO ASSET ===');
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// 1. Tìm các Asset chưa có trường fileSize hoặc fileSize bằng null/0
|
||||||
|
const assetsToFix = await Asset.find({
|
||||||
|
$or: [
|
||||||
|
{ fileSize: { $exists: false } },
|
||||||
|
{ fileSize: null },
|
||||||
|
{ fileSize: 0 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`- Tìm thấy ${assetsToFix.length} bản ghi cần cập nhật.`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
let missingFileCount = 0;
|
||||||
|
|
||||||
|
for (const asset of assetsToFix) {
|
||||||
|
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(asset.filePath);
|
||||||
|
asset.fileSize = stats.size;
|
||||||
|
await asset.save();
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
if (successCount % 10 === 0) {
|
||||||
|
console.log(` [Progress] Đã cập nhật ${successCount} file...`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` [Lỗi] Không thể đọc stats cho Asset ${asset._id}: ${err.message}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(` [Cảnh báo] Không tìm thấy file vật lý cho Asset ${asset._id}: ${asset.filePath}`);
|
||||||
|
missingFileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== TỔNG KẾT QUÁ TRÌNH ===');
|
||||||
|
console.log(`- Thành công: ${successCount}`);
|
||||||
|
console.log(`- Lỗi đọc file: ${errorCount}`);
|
||||||
|
console.log(`- File không tồn tại trên đĩa: ${missingFileCount}`);
|
||||||
|
console.log('==========================================');
|
||||||
|
|
||||||
|
mongoose.connection.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi nghiêm trọng:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fixAssetFileSize();
|
||||||
@@ -50,10 +50,10 @@ const getStorageStats = async () => {
|
|||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const userId = asset.uploadedBy ? asset.uploadedBy.toString() : 'unknown';
|
const userId = asset.uploadedBy ? asset.uploadedBy.toString() : 'unknown';
|
||||||
|
|
||||||
if (asset.filePath && fs.existsSync(asset.filePath)) {
|
if (asset.fileSize || (asset.filePath && fs.existsSync(asset.filePath))) {
|
||||||
const fileStat = fs.statSync(asset.filePath);
|
const size = asset.fileSize || fs.statSync(asset.filePath).size;
|
||||||
if (stats[userId]) {
|
if (stats[userId]) {
|
||||||
stats[userId].totalBytes += fileStat.size;
|
stats[userId].totalBytes += size;
|
||||||
stats[userId].fileCount += 1;
|
stats[userId].fileCount += 1;
|
||||||
} else {
|
} else {
|
||||||
stats['unknown'].totalBytes += fileStat.size;
|
stats['unknown'].totalBytes += fileStat.size;
|
||||||
|
|||||||
@@ -422,6 +422,69 @@ html, body {
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Storage Progress Bar */
|
||||||
|
.storage-info {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.storage-info label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.progress-container {
|
||||||
|
height: 8px;
|
||||||
|
background: #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: #28a745;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
#storage-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-files-section {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.top-files-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffd700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.top-file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.top-file-name {
|
||||||
|
color: #eee;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
.top-file-size { color: #888; font-family: monospace; }
|
||||||
|
|
||||||
/* Dashboard List Styles */
|
/* Dashboard List Styles */
|
||||||
.dashboard-list {
|
.dashboard-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+95
-10
@@ -133,23 +133,107 @@ function applySystemSettings() {
|
|||||||
if (logoutBtn) logoutBtn.innerText = t.logout;
|
if (logoutBtn) logoutBtn.innerText = t.logout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hàm định dạng dung lượng file cho Frontend
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes, decimals = 2) {
|
||||||
|
if (!bytes || bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tải và hiển thị thống kê các tệp tin lớn nhất
|
||||||
|
*/
|
||||||
|
async function loadMediaStats() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const statsContainer = document.getElementById('media-library-stats');
|
||||||
|
if (!statsContainer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/me/assets/top-large`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const topFiles = await res.json();
|
||||||
|
|
||||||
|
if (topFiles && topFiles.length > 0) {
|
||||||
|
let html = `
|
||||||
|
<div class="top-files-section">
|
||||||
|
<h4><i class="fas fa-database"></i> Tệp tin chiếm dụng lớn nhất</h4>
|
||||||
|
<div class="top-files-list">
|
||||||
|
`;
|
||||||
|
topFiles.forEach(file => {
|
||||||
|
const fileName = file.scene?.name || file.scene?.title || 'Ảnh chưa gắn Scene';
|
||||||
|
html += `
|
||||||
|
<div class="top-file-item">
|
||||||
|
<span class="top-file-name">● ${fileName}</span>
|
||||||
|
<span class="top-file-size">${formatBytes(file.fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += `</div></div>`;
|
||||||
|
statsContainer.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
statsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Không thể nạp thống kê media:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cập nhật nội dung tab Hồ sơ với thông tin người dùng
|
* Cập nhật nội dung tab Hồ sơ với thông tin người dùng
|
||||||
*/
|
*/
|
||||||
function updateProfileTabContent() {
|
async function updateProfileTabContent() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
const username = localStorage.getItem('username');
|
const username = localStorage.getItem('username');
|
||||||
const role = localStorage.getItem('role');
|
const role = localStorage.getItem('role');
|
||||||
|
|
||||||
if (username) {
|
const avatar = document.getElementById('profile-avatar-initials');
|
||||||
const avatar = document.getElementById('profile-avatar-initials');
|
const userDisplay = document.getElementById('profile-username-display');
|
||||||
const userDisplay = document.getElementById('profile-username-display');
|
const statusDisplay = document.getElementById('profile-status-display');
|
||||||
const statusDisplay = document.getElementById('profile-status-display');
|
const userInput = document.getElementById('profile-username');
|
||||||
const userInput = document.getElementById('profile-username');
|
|
||||||
|
|
||||||
if (avatar) avatar.innerText = username.charAt(0).toUpperCase();
|
if (avatar && username) avatar.innerText = username.charAt(0).toUpperCase();
|
||||||
if (userDisplay) userDisplay.innerText = username;
|
if (userDisplay) userDisplay.innerText = username;
|
||||||
if (statusDisplay) statusDisplay.innerText = role || 'Thành viên';
|
if (statusDisplay) statusDisplay.innerText = role || 'Thành viên';
|
||||||
if (userInput) userInput.value = username;
|
if (userInput) userInput.value = username;
|
||||||
|
|
||||||
|
// Lấy dữ liệu dung lượng thực tế từ server
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/me/profile`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.storage) {
|
||||||
|
const { used, quota } = data.storage;
|
||||||
|
const progress = document.getElementById('storage-progress-bar');
|
||||||
|
const text = document.getElementById('storage-text');
|
||||||
|
|
||||||
|
if (progress && text) {
|
||||||
|
const usedMB = (used / (1024 * 1024)).toFixed(1);
|
||||||
|
const quotaMB = quota === -1 ? '∞' : (quota / (1024 * 1024)).toFixed(0);
|
||||||
|
text.innerText = `${usedMB} MB / ${quotaMB} MB`;
|
||||||
|
|
||||||
|
if (quota !== -1) {
|
||||||
|
const percent = Math.min((used / quota) * 100, 100);
|
||||||
|
progress.style.width = percent + '%';
|
||||||
|
// Đổi màu thanh tiến trình dựa trên mức độ sử dụng
|
||||||
|
if (percent > 90) progress.style.background = '#dc3545'; // Đỏ (sắp hết)
|
||||||
|
else if (percent > 75) progress.style.background = '#ffc107'; // Vàng (cảnh báo)
|
||||||
|
else progress.style.background = '#28a745'; // Xanh (an toàn)
|
||||||
|
} else {
|
||||||
|
progress.style.width = '100%';
|
||||||
|
progress.style.background = '#007bff'; // Màu xanh dương cho không giới hạn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Không thể tải thông tin dung lượng:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,6 +2037,7 @@ function openDashboardTab(tabName) {
|
|||||||
loadMyScenes();
|
loadMyScenes();
|
||||||
}
|
}
|
||||||
if (tabName === 'media-library') {
|
if (tabName === 'media-library') {
|
||||||
|
loadMediaStats();
|
||||||
loadMyAssets();
|
loadMyAssets();
|
||||||
}
|
}
|
||||||
if (tabName === 'user-management') {
|
if (tabName === 'user-management') {
|
||||||
|
|||||||
Reference in New Issue
Block a user