diff --git a/backend/middlewares/quotaMiddleware.js b/backend/middlewares/quotaMiddleware.js new file mode 100644 index 0000000..292c4dd --- /dev/null +++ b/backend/middlewares/quotaMiddleware.js @@ -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 }; \ No newline at end of file diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 9397aaf..6357a42 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -13,6 +13,7 @@ const Setting = require('../models/Setting'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); +const { checkQuota, ROLE_QUOTAS } = require('../middlewares/quotaMiddleware'); const { resizeTo8K } = require('../utils/imageHelper'); const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper'); 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) * @access Private (Registered Users) */ -router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { +router.post('/scenes', protect, uploadSinglePanorama, checkQuota, async (req, res) => { try { 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 const asset = new Asset({ 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, 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) => { try { - const user = await User.findById(req.user._id).select('-password'); - res.json(user); + const user = await User.findById(req.user._id).select('-password').lean(); + + // 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) { res.status(500).json({ message: error.message }); } diff --git a/backend/scripts/fixAssetFileSize.js b/backend/scripts/fixAssetFileSize.js new file mode 100644 index 0000000..c9b912e --- /dev/null +++ b/backend/scripts/fixAssetFileSize.js @@ -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(); \ No newline at end of file diff --git a/backend/scripts/storageStats.js b/backend/scripts/storageStats.js index 4ec8563..0c3922b 100644 --- a/backend/scripts/storageStats.js +++ b/backend/scripts/storageStats.js @@ -50,10 +50,10 @@ const getStorageStats = async () => { for (const asset of assets) { const userId = asset.uploadedBy ? asset.uploadedBy.toString() : 'unknown'; - if (asset.filePath && fs.existsSync(asset.filePath)) { - const fileStat = fs.statSync(asset.filePath); + if (asset.fileSize || (asset.filePath && fs.existsSync(asset.filePath))) { + const size = asset.fileSize || fs.statSync(asset.filePath).size; if (stats[userId]) { - stats[userId].totalBytes += fileStat.size; + stats[userId].totalBytes += size; stats[userId].fileCount += 1; } else { stats['unknown'].totalBytes += fileStat.size; diff --git a/frontend/css/style.css b/frontend/css/style.css index c7bb398..c35097c 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -422,6 +422,69 @@ html, body { 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 { display: flex; diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 2424509..093e9d4 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -133,23 +133,107 @@ function applySystemSettings() { 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 = ` +