diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4941377 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,239 @@ +# 3D Virtual Tour Map - Architecture Diagram + +## Technology Stack + +### Backend +- **Runtime**: Node.js +- **Framework**: Express.js +- **Database**: MongoDB (Mongoose ODM) +- **Authentication**: JWT (jsonwebtoken) + bcrypt +- **Image Processing**: Sharp (resize), exifr (read EXIF), piexifjs (write EXIF) +- **File Upload**: Multer +- **Security**: CORS, referer verification, no-cache headers + +### Frontend +- **Map**: Leaflet.js (OpenStreetMap tiles) +- **3D Viewer**: Pannellum.js (360° panorama viewer) +- **UI**: Vanilla HTML/CSS/JavaScript +- **State**: localStorage for JWT tokens + +## Architecture Diagram + +```mermaid +graph TB + subgraph "Client (Browser)" + UI[HTML/CSS UI] + MAP[Leaflet Map] + VIEWER[Pannellum 3D Viewer] + AUTH[Auth Panel] + MODAL[Scene Creation Modal] + end + + subgraph "Backend (Node.js/Express)" + SERVER[server.js] + ROUTES[API Routes] + MIDDLEWARES[Security & Auth Middlewares] + UTILS[Image & EXIF Utils] + MODELS[Mongoose Models] + end + + subgraph "Database (MongoDB)" + USERS[Users Collection] + SCENES[Scenes Collection] + ASSETS[Assets Collection] + end + + subgraph "File System" + UPLOADS[uploads/ Directory] + TEMP[temp/ Directory] + end + + UI --> MAP + UI --> AUTH + UI --> MODAL + UI --> VIEWER + + MAP -->|Right-click| MODAL + MAP -->|Load Scenes| ROUTES + MAP -->|Click Marker| VIEWER + + AUTH -->|Login/Register| ROUTES + AUTH -->|Store JWT| UI + + MODAL -->|Upload Image| ROUTES + + VIEWER -->|Request Image| ROUTES + + ROUTES --> MIDDLEWARES + MIDDLEWARES -->|Verify JWT| AUTH + MIDDLEWARES -->|Verify Referer| UI + MIDDLEWARES -->|Privacy Check| SCENES + + ROUTES --> MODELS + MODELS --> USERS + MODELS --> SCENES + MODELS --> ASSETS + + ROUTES --> UTILS + UTILS -->|Resize| UPLOADS + UTILS -->|Read/Write EXIF| UPLOADS + UTILS -->|Temp Storage| TEMP + + SCENES --> ASSETS + ASSETS --> UPLOADS + + SERVER --> ROUTES + SERVER -->|Serve Static| UI +``` + +## Data Flow + +### 1. User Registration/Login +``` +Client → POST /api/auth/register or /api/auth/login + → authRoutes.js + → User model (bcrypt hash/compare) + → JWT generation + → Response with token + → Client stores token in localStorage +``` + +### 2. Scene Creation (Upload 360° Image) +``` +Client → Right-click on map → Open modal with lat/lng + → POST /api/scenes (with multipart/form-data) + → authMiddleware.protect (verify JWT) + → Multer saves to temp/ + → imageHelper.resizeTo8K (resize to 8192x4096) + → exifHelper.getGPSCoordinates (read original GPS) + → exifHelper.injectGPSCoordinates (inject map lat/lng) + → Asset model saved to DB + → Scene model saved to DB (with privacy settings) + → Delete temp file + → Response with scene data +``` + +### 3. Load Scenes on Map +``` +Client → GET /api/scenes (with optional JWT) + → authMiddleware.optionalAuth + → Scene.find() with privacy filter: + - Guests: public + shared scenes + - Logged-in: public + member + owned + shared-with-me + → Populate owner and asset data + → Response with scene list + → Client adds markers to Leaflet map +``` + +### 4. View 3D Panorama +``` +Client → Click marker → GET /api/scenes/:id (with token if shared) + → authMiddleware.optionalAuth + → Privacy verification + → Response with scene details + → Client constructs secure image URL: /api/assets/view/:assetId?token=... + → GET /api/assets/view/:assetId + → securityMiddleware.verifyReferer (anti-hotlinking) + → securityMiddleware.setNoCacheHeaders + → Privacy verification again + → Stream image file from disk + → Pannellum viewer displays 360° panorama + → Client-side security: block right-click, drag, keyboard shortcuts +``` + +## Security Layers + +### Backend Security +1. **JWT Authentication**: Required for creating scenes, optional for viewing +2. **Privacy Model**: Four levels (public, private, member, shared) +3. **Referer Verification**: Prevents direct URL access to images +4. **No-Cache Headers**: Prevents browser caching of protected images +5. **Share Tokens**: For shared scenes, token required for access + +### Frontend Security +1. **Right-click Blocking**: Prevents image saving in viewer +2. **Drag Prevention**: Blocks drag-and-drop of images +3. **Keyboard Restrictions**: Blocks F12, Ctrl+S, Ctrl+U, Ctrl+Shift+I +4. **Token-based Access**: Share tokens passed in URLs for shared content + +## Database Schema + +### User Model +```javascript +{ + username: String (unique, required), + password: String (bcrypt hashed), + role: Enum ['Chủ sở hữu', 'Thành viên'], + timestamps: true +} +``` + +### Scene Model +```javascript +{ + title: String (required), + assetId: ObjectId (ref: Asset), + lat: Number (required), + lng: Number (required), + owner: ObjectId (ref: User), + privacy: Enum ['public', 'private', 'shared', 'member'], + shareToken: String (unique, sparse), + sharedWith: [ObjectId] (ref: User), + timestamps: true +} +``` + +### Asset Model +```javascript +{ + filePath: String (required), + uploadedBy: ObjectId (ref: User), + coordinates: { lat: Number, lng: Number }, + timestamps: true +} +``` + +## File Structure + +``` +3dtours/ +├── backend/ +│ ├── config/ +│ │ └── db.js # MongoDB connection +│ ├── middlewares/ +│ │ ├── authMiddleware.js # JWT verification +│ │ └── securityMiddleware.js # Referer check, cache control +│ ├── models/ +│ │ ├── User.js # User schema +│ │ ├── Scene.js # Scene schema +│ │ └── Asset.js # Asset schema +│ ├── routes/ +│ │ ├── authRoutes.js # Login/register endpoints +│ │ └── apiRoutes.js # Scenes/assets endpoints +│ ├── utils/ +│ │ ├── imageHelper.js # Sharp resize to 8K +│ │ └── exifHelper.js # GPS read/write +│ ├── uploads/ # Processed images +│ │ └── temp/ # Temporary upload storage +│ ├── server.js # Express app entry point +│ ├── package.json +│ └── .env # Environment variables +└── frontend/ + ├── css/ + │ └── style.css # UI styling + ├── js/ + │ ├── main_map.js # Map logic, auth, scene loading + │ └── viewer360.js # Pannellum viewer + security + └── index.html # Main UI +``` + +## Key Features + +1. **Interactive Map**: Leaflet-based map with scene markers +2. **3D Panorama Viewer**: Pannellum for 360° image viewing +3. **User Authentication**: Registration, login with role-based access +4. **Privacy Controls**: Public, private, member-only, and shared scenes +5. **Image Processing**: Automatic resize to 8K (8192x4096) for consistency +6. **GPS Handling**: Extract original EXIF GPS, inject map coordinates +7. **Security**: Multi-layer protection against unauthorized access +8. **Share Links**: Token-based sharing for restricted content diff --git a/backend/config/db.js b/backend/config/db.js index 34a5d39..6f1b3f0 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -1,8 +1,19 @@ const mongoose = require('mongoose'); +const path = require('path'); +const dotenv = require('dotenv'); + +// Tự động tìm và nạp file .env nằm cùng thư mục với folder config (tức là trong backend/.env) +dotenv.config({ path: path.join(__dirname, '../.env') }); const connectDB = async () => { try { - const conn = await mongoose.connect(process.env.MONGODB_URI); + const dbURI = process.env.MONGODB_URI; + + if (!dbURI) { + throw new Error('MONGODB_URI is not defined in .env file'); + } + + const conn = await mongoose.connect(dbURI); console.log(`MongoDB Connected: ${conn.connection.host}`); } catch (error) { console.error(`Error connecting to MongoDB: ${error.message}`); diff --git a/backend/middlewares/authMiddleware.js b/backend/middlewares/authMiddleware.js index 0d7db87..d07283c 100644 --- a/backend/middlewares/authMiddleware.js +++ b/backend/middlewares/authMiddleware.js @@ -6,9 +6,14 @@ const User = require('../models/User'); */ const protect = async (req, res, next) => { let token; - if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + if ( + (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) || + req.query.token + ) { try { - token = req.headers.authorization.split(' ')[1]; + token = req.headers.authorization + ? req.headers.authorization.split(' ')[1] + : req.query.token; const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = await User.findById(decoded.id).select('-password'); if (!req.user) { @@ -28,9 +33,14 @@ const protect = async (req, res, next) => { * but allows the request to proceed as a guest if no token is found. */ const optionalAuth = async (req, res, next) => { - if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { + if ( + (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) || + req.query.token + ) { try { - const token = req.headers.authorization.split(' ')[1]; + const token = req.headers.authorization + ? req.headers.authorization.split(' ')[1] + : req.query.token; const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = await User.findById(decoded.id).select('-password'); } catch (error) { diff --git a/backend/middlewares/securityMiddleware.js b/backend/middlewares/securityMiddleware.js index 6febe27..6a62d25 100644 --- a/backend/middlewares/securityMiddleware.js +++ b/backend/middlewares/securityMiddleware.js @@ -19,9 +19,16 @@ const verifyReferer = (req, res, next) => { const isMatch = (headerValue) => { if (!headerValue) return false; try { - return new URL(headerValue).origin === allowedOrigin; + const urlObj = new URL(headerValue); + const incomingOrigin = urlObj.origin; + // Cho phép nếu khớp hoàn toàn origin + if (incomingOrigin === allowedOrigin) return true; + // Trong môi trường development, cho phép localhost với bất kỳ port nào + const isLocal = incomingOrigin.includes('localhost') || incomingOrigin.includes('127.0.0.1') || incomingOrigin.includes('::1'); + if (process.env.NODE_ENV !== 'production' && isLocal) return true; + return false; } catch (e) { - return headerValue.startsWith(allowedOrigin); + return false; } }; diff --git a/backend/models/Scene.js b/backend/models/Scene.js index 337541c..b88d2e0 100644 --- a/backend/models/Scene.js +++ b/backend/models/Scene.js @@ -37,6 +37,28 @@ const sceneSchema = new mongoose.Schema({ sharedWith: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' + }], + hotspots: [{ + pitch: { + type: Number, + required: true + }, + yaw: { + type: Number, + required: true + }, + text: { + type: String, + trim: true + }, + description: { + type: String, + trim: true + }, + targetSceneId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Scene' + } }] }, { timestamps: true diff --git a/backend/models/User.js b/backend/models/User.js index aa016a0..55df42e 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -22,16 +22,15 @@ const userSchema = new mongoose.Schema({ }); // Hash password before saving -userSchema.pre('save', async function (next) { +userSchema.pre('save', async function () { if (!this.isModified('password')) { - return next(); + return; } try { const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); - next(); } catch (error) { - next(error); + throw error; } }); diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index d24e6a4..d62e9bc 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -69,25 +69,29 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => { const processedFileName = `processed_${req.file.filename}.jpg`; const processedFilePath = path.join(uploadDir, processedFileName); - // 1. Process and resize image to 8K JPEG (8192x4096) - await resizeTo8K(tempFilePath, processedFilePath); - - // 2. Analyze EXIF GPS from original file + // Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý const originalGPS = await getGPSCoordinates(tempFilePath); - // 3. Inject Map Lat/Lng coordinates into processed 8K file binary EXIF - await injectGPSCoordinates(processedFilePath, latitude, longitude); - - // 4. Remove original temp file - if (fs.existsSync(tempFilePath)) { - fs.unlinkSync(tempFilePath); - } + // BACKGROUND PROCESSING: Thực hiện song song không chặn response + setImmediate(async () => { + try { + // 1. Resize to 8K + await resizeTo8K(tempFilePath, processedFilePath); + // 2. Inject GPS + await injectGPSCoordinates(processedFilePath, latitude, longitude); + // 3. Cleanup temp file + if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); + console.log(`Background processing finished for: ${processedFileName}`); + } catch (err) { + console.error(`Background Image processing failed: ${err.message}`); + } + }); // 5. Save Asset to DB const asset = new Asset({ filePath: processedFilePath, uploadedBy: req.user._id, - coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : undefined + coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude } }); await asset.save(); @@ -120,7 +124,7 @@ router.post('/scenes', protect, upload.single('panorama'), async (req, res) => { }); await scene.save(); - res.status(201).json({ + res.status(202).json({ message: 'Scene created successfully', scene }); @@ -181,7 +185,7 @@ router.get('/scenes', optionalAuth, async (req, res) => { */ router.get('/scenes/:id', optionalAuth, async (req, res) => { try { - const scene = await Scene.findById(req.id || req.params.id) + const scene = await Scene.findById(req.params.id) .populate('owner', 'username') .populate('assetId'); @@ -206,12 +210,68 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => { } }); +/** + * @route POST /api/scenes/:id/hotspots + * @desc Add a new hotspot to a scene + * @access Private (Owner only) + */ +router.post('/scenes/:id/hotspots', protect, async (req, res) => { + try { + const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body; + const scene = await Scene.findById(req.params.id); + + if (!scene) { + return res.status(404).json({ message: 'Scene not found' }); + } + + // Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots + if (scene.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' }); + } + + if (hotspotId) { + // CẬP NHẬT HOTSPOT HIỆN CÓ + const hs = scene.hotspots.id(hotspotId); + if (!hs) return res.status(404).json({ message: 'Hotspot not found' }); + + hs.pitch = parseFloat(pitch) ?? hs.pitch; + hs.yaw = parseFloat(yaw) ?? hs.yaw; + hs.text = text ?? hs.text; + hs.description = description ?? hs.description; + hs.targetSceneId = targetSceneId ?? hs.targetSceneId; + } else { + // THÊM MỚI HOTSPOT + const newHotspot = { + pitch: parseFloat(pitch), + yaw: parseFloat(yaw), + text: text || '', + description: description || '', + targetSceneId: targetSceneId || undefined + }; + + if (isNaN(newHotspot.pitch) || isNaN(newHotspot.yaw)) { + return res.status(400).json({ message: 'Invalid coordinates' }); + } + scene.hotspots.push(newHotspot); + } + + await scene.save(); + + res.status(201).json({ + message: 'Hotspot added successfully', + hotspots: scene.hotspots + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + /** * @route GET /api/assets/view/:assetId * @desc Securely stream panorama images (prevents direct link / unauthorized downloads) * @access Public / Private + Referer Verification + Token validation */ -router.get('/assets/view/:assetId', verifyReferer, setNoCacheHeaders, optionalAuth, async (req, res) => { +router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => { try { const asset = await Asset.findById(req.params.assetId); if (!asset) { @@ -242,12 +302,98 @@ router.get('/assets/view/:assetId', verifyReferer, setNoCacheHeaders, optionalAu return res.status(404).json({ message: 'Physical file not found on disk' }); } - // Stream file securely - res.sendFile(path.resolve(asset.filePath)); + const resolvedPath = path.resolve(asset.filePath); + + // Sử dụng res.sendFile để tối ưu hóa việc truyền tải file lớn và hỗ trợ Caching (ETag) + res.sendFile(resolvedPath, { + maxAge: 2592000000, // 30 ngày (tính bằng ms) + lastModified: true, + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Disposition': 'inline; filename="panorama.jpg"', + 'Cache-Control': 'public, max-age=2592000, immutable' // Buộc trình duyệt lấy từ cache mà không cần hỏi lại server + } + }); } catch (error) { res.status(500).json({ message: error.message }); } }); +/** + * @route PUT /api/scenes/:id + * @desc Update an existing scene + * @access Private (Owner only) + */ +router.put('/scenes/:id', protect, upload.single('panorama'), async (req, res) => { + try { + const { title, privacy, sharedWithUsers, lat, lng } = req.body; + const scene = await Scene.findById(req.params.id); + + if (!scene || scene.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Not authorized' }); + } + + // Update basic info + scene.title = title || scene.title; + scene.privacy = privacy || scene.privacy; + scene.lat = lat ? parseFloat(lat) : scene.lat; + scene.lng = lng ? parseFloat(lng) : scene.lng; + + if (privacy === 'shared' && !scene.shareToken) { + scene.shareToken = crypto.randomBytes(24).toString('hex'); + } + + // Update image if new one is uploaded + if (req.file) { + const processedFileName = `processed_${req.file.filename}.jpg`; + const processedFilePath = path.join(uploadDir, processedFileName); + + await resizeTo8K(req.file.path, processedFilePath); + await injectGPSCoordinates(processedFilePath, scene.lat, scene.lng); + + const asset = new Asset({ + filePath: processedFilePath, + uploadedBy: req.user._id + }); + await asset.save(); + scene.assetId = asset._id; + + if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); + } + + await scene.save(); + res.json({ message: 'Scene updated', scene }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route DELETE /api/scenes/:id + * @desc Delete a scene and its assets + * @access Private (Owner only) + */ +router.delete('/scenes/:id', protect, async (req, res) => { + try { + const scene = await Scene.findById(req.params.id); + if (!scene || scene.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ message: 'Not authorized' }); + } + + // Delete physical file if exists + const asset = await Asset.findById(scene.assetId); + if (asset && fs.existsSync(asset.filePath)) { + fs.unlinkSync(asset.filePath); + } + + await Asset.findByIdAndDelete(scene.assetId); + await Scene.findByIdAndDelete(req.params.id); + + res.json({ message: 'Scene deleted successfully' }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + module.exports = router; diff --git a/backend/server.js b/backend/server.js index 0e9e427..ad40623 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,10 +1,6 @@ const express = require('express'); const cors = require('cors'); const path = require('path'); -const dotenv = require('dotenv'); - -// Load environment variables -dotenv.config(); const connectDB = require('./config/db'); const authRoutes = require('./routes/authRoutes'); @@ -16,7 +12,34 @@ connectDB(); const app = express(); // Standard middlewares -app.use(cors()); +const corsOptions = { + origin: function (origin, callback) { + // Cho phép các request không có origin (như Postman hoặc khi render phía server) + if (!origin) return callback(null, true); + + const systemHost = process.env.SYSTEM_HOST || 'http://localhost:5000'; + let allowedOrigin; + try { + allowedOrigin = new URL(systemHost).origin; + } catch (e) { + allowedOrigin = systemHost; + } + + // Trong môi trường dev, cho phép localhost với bất kỳ port nào + const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('::1'); + if (process.env.NODE_ENV !== 'production' && isLocal) { + return callback(null, true); + } + + if (origin === allowedOrigin) return callback(null, true); + + console.warn(`[CORS Blocked]: Origin ${origin} is not allowed by configuration.`); + callback(new Error('Not allowed by CORS')); + }, + credentials: true, + maxAge: 86400 // Cho phép trình duyệt cache kết quả preflight OPTIONS trong 24 giờ +}; +app.use(cors(corsOptions)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -35,6 +58,6 @@ app.use((req, res) => { const PORT = process.env.PORT || 5000; app.listen(PORT, () => { - console.log(`Server is running in security mode on port ${PORT}`); + console.log(`Server is running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`); console.log(`System Host (Referer origin check) set to: ${process.env.SYSTEM_HOST || 'http://localhost:5000'}`); }); diff --git a/backend/uploads/processed_1780829789113_f05e5507.JPG.jpg b/backend/uploads/processed_1780829789113_f05e5507.JPG.jpg new file mode 100644 index 0000000..a6dc884 Binary files /dev/null and b/backend/uploads/processed_1780829789113_f05e5507.JPG.jpg differ diff --git a/backend/uploads/processed_1780829797452_955cbbfe.JPG.jpg b/backend/uploads/processed_1780829797452_955cbbfe.JPG.jpg new file mode 100644 index 0000000..a6dc884 Binary files /dev/null and b/backend/uploads/processed_1780829797452_955cbbfe.JPG.jpg differ diff --git a/backend/uploads/processed_1780839280131_2afe864c.JPG.jpg b/backend/uploads/processed_1780839280131_2afe864c.JPG.jpg new file mode 100644 index 0000000..79b7421 Binary files /dev/null and b/backend/uploads/processed_1780839280131_2afe864c.JPG.jpg differ diff --git a/backend/uploads/processed_1780840714376_be694e76.JPG.jpg b/backend/uploads/processed_1780840714376_be694e76.JPG.jpg new file mode 100644 index 0000000..4e3ced3 Binary files /dev/null and b/backend/uploads/processed_1780840714376_be694e76.JPG.jpg differ diff --git a/backend/uploads/processed_1780840799223_59b417b9.JPG.jpg b/backend/uploads/processed_1780840799223_59b417b9.JPG.jpg new file mode 100644 index 0000000..4e3ced3 Binary files /dev/null and b/backend/uploads/processed_1780840799223_59b417b9.JPG.jpg differ diff --git a/backend/uploads/processed_1780841484128_0790991a.JPG.jpg b/backend/uploads/processed_1780841484128_0790991a.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780841484128_0790991a.JPG.jpg differ diff --git a/backend/uploads/processed_1780841540897_3ab71fcd.JPG.jpg b/backend/uploads/processed_1780841540897_3ab71fcd.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780841540897_3ab71fcd.JPG.jpg differ diff --git a/backend/uploads/processed_1780841603501_89076676.JPG.jpg b/backend/uploads/processed_1780841603501_89076676.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780841603501_89076676.JPG.jpg differ diff --git a/backend/uploads/processed_1780842150898_687ea48c.JPG.jpg b/backend/uploads/processed_1780842150898_687ea48c.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780842150898_687ea48c.JPG.jpg differ diff --git a/backend/uploads/processed_1780842325753_ca34af30.JPG.jpg b/backend/uploads/processed_1780842325753_ca34af30.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780842325753_ca34af30.JPG.jpg differ diff --git a/backend/uploads/processed_1780842476612_b84135ff.JPG.jpg b/backend/uploads/processed_1780842476612_b84135ff.JPG.jpg new file mode 100644 index 0000000..dfe5ad1 Binary files /dev/null and b/backend/uploads/processed_1780842476612_b84135ff.JPG.jpg differ diff --git a/backend/uploads/processed_1780842543486_64531ab7.JPG.jpg b/backend/uploads/processed_1780842543486_64531ab7.JPG.jpg new file mode 100644 index 0000000..5f26fc4 Binary files /dev/null and b/backend/uploads/processed_1780842543486_64531ab7.JPG.jpg differ diff --git a/backend/uploads/temp/1780829804457_08557d86.JPG b/backend/uploads/temp/1780829804457_08557d86.JPG new file mode 100644 index 0000000..7d57932 Binary files /dev/null and b/backend/uploads/temp/1780829804457_08557d86.JPG differ diff --git a/backend/uploads/temp/1780829805324_e2790a35.JPG b/backend/uploads/temp/1780829805324_e2790a35.JPG new file mode 100644 index 0000000..4592ad9 Binary files /dev/null and b/backend/uploads/temp/1780829805324_e2790a35.JPG differ diff --git a/backend/uploads/temp/1780829807673_e1dc797f.JPG b/backend/uploads/temp/1780829807673_e1dc797f.JPG new file mode 100644 index 0000000..3f9b6b1 Binary files /dev/null and b/backend/uploads/temp/1780829807673_e1dc797f.JPG differ diff --git a/backend/utils/exifHelper.js b/backend/utils/exifHelper.js index 6064608..fe08ff6 100644 --- a/backend/utils/exifHelper.js +++ b/backend/utils/exifHelper.js @@ -48,18 +48,30 @@ const degToDmsRational = (deg) => { */ const injectGPSCoordinates = async (filePath, lat, lng) => { try { - const jpegBinary = fs.readFileSync(filePath).toString('binary'); + const data = fs.readFileSync(filePath); - let exifObj = { "0th": {}, "Exif": {}, "GPS": {} }; + // Kiểm tra marker SOI (Start of Image) trực tiếp trên Buffer + if (data[0] !== 0xFF || data[1] !== 0xD8) { + throw new Error("Tệp tin không phải là định dạng JPEG hợp lệ (thiếu SOI marker)."); + } + + const jpegBinary = data.toString('binary'); + let exifObj; try { exifObj = piexif.load(jpegBinary); } catch (e) { - // No existing EXIF, start clean + // Nếu không có EXIF hoặc lỗi khi nạp, khởi tạo đối tượng sạch + exifObj = { "0th": {}, "Exif": {}, "GPS": {} }; } + // Đảm bảo các cấu trúc IFD tồn tại trước khi ghi đè + exifObj["GPS"] = exifObj["GPS"] || {}; + const latRef = lat >= 0 ? 'N' : 'S'; const lngRef = lng >= 0 ? 'E' : 'W'; + // Thêm Version ID (Bắt buộc để một số trình đọc nhận diện khối GPS) + exifObj["GPS"][piexif.GPSIFD.GPSVersionID] = [2, 2, 0, 0]; exifObj["GPS"][piexif.GPSIFD.GPSLatitudeRef] = latRef; exifObj["GPS"][piexif.GPSIFD.GPSLatitude] = degToDmsRational(lat); exifObj["GPS"][piexif.GPSIFD.GPSLongitudeRef] = lngRef; diff --git a/backend/utils/imageHelper.js b/backend/utils/imageHelper.js index c2f3ef4..a55fedc 100644 --- a/backend/utils/imageHelper.js +++ b/backend/utils/imageHelper.js @@ -8,10 +8,17 @@ const sharp = require('sharp'); const resizeTo8K = async (inputPath, outputPath) => { try { await sharp(inputPath) + .rotate() // Tự động xoay ảnh dựa trên EXIF orientation .resize(8192, 4096, { fit: 'fill' // Ensures the output is exactly 8192x4096 }) - .jpeg({ quality: 90 }) + .jpeg({ + quality: 85, + progressive: false, // Tắt progressive để header đơn giản hơn cho piexifjs + chromaSubsampling: '4:2:0' + }) + // Loại bỏ .withMetadata() để Sharp tạo ra file JPEG sạch nhất. + // Điều này giúp piexifjs không bị lỗi "Given data is not jpeg". .toFile(outputPath); } catch (error) { throw new Error(`Sharp image processing failed: ${error.message}`); diff --git a/frontend/css/style.css b/frontend/css/style.css index 9e29710..af30034 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -169,3 +169,47 @@ html, body { #close-viewer-btn:hover { background: white; } +/* Modal Overlay */ +.modal-overlay { + position: fixed; + top: 0; left: 0; width: 100%; height: 100%; + background: rgba(0,0,0,0.7); + display: flex; align-items: center; justify-content: center; + z-index: 2000; +} + +.modal-content { + background: #fff; + padding: 20px; + border-radius: 8px; + width: 90%; max-width: 500px; + box-shadow: 0 4px 15px rgba(0,0,0,0.3); +} + +.modal-header { + display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; +} + +.form-group { margin-bottom: 15px; } +.form-group label { display: block; font-weight: bold; margin-bottom: 5px; } +.form-group input, .form-group textarea, .form-group select { + width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; +} + +/* Tabs */ +.tab-container { display: flex; gap: 10px; margin-bottom: 10px; } +.tab-btn { + flex: 1; padding: 8px; cursor: pointer; border: 1px solid #007bff; + background: #fff; color: #007bff; border-radius: 4px; +} +.tab-btn.active { background: #007bff; color: #fff; } + +.divider { border-top: 1px dashed #ccc; padding-top: 15px; } + +.modal-footer { + display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; +} + +.save-btn { background: #28a745; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; } +.cancel-btn { background: #6c757d; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; } diff --git a/frontend/index.html b/frontend/index.html index bf760ef..ea396f6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -39,6 +39,8 @@ ×