Lỗi có thể di chuyển tù sharedlink sang public nhưng không có hotspot quay lại

This commit is contained in:
2026-06-11 16:27:37 +07:00
parent be149f26ca
commit 0434837026
7 changed files with 1055 additions and 47 deletions
+923
View File
@@ -0,0 +1,923 @@
# Tour/Scene Privacy Logic Analysis Report
## Executive Summary
The privacy system has **critical design flaws** that compromise security. While Tour-level privacy management is correctly implemented with proper propagation to scenes, the **Scene model is missing the `privacy` field** in its schema definition, creating a fundamental data structure issue. Additionally, there are inconsistencies between the intended API requirements and actual implementation.
### Critical Issues Found: 8
### High Priority Issues: 5
### Medium Priority Issues: 3
---
## Requirements from ARCHITEC.md
```
- Public: Everyone can see. Only creator can manage
- Private: Only creator can see and manage
- Shared link: Everyone with valid token can see. Only creator can manage
- Member: Only members can see. Only creator can manage
```
---
## Detailed Findings
## 1. GET /api/tours/:id - Tour Detail Endpoint
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L82-L125)
```javascript
router.get('/:id', optionalAuth, async (req, res) => {
const tour = await Tour.findById(req.params.id)
.populate('createdBy', 'username')
.populate({ path: 'rootSceneId', ... })
.populate({ path: 'scenes', ... })
.lean();
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) ||
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (!hasAccess) return res.status(403).json({ message: 'Unauthorized' });
res.json(tour);
});
```
### Expected Behavior (Per ARCHITEC.md)
1.**Public**: Anyone can view
2.**Private**: Only creator
3.**Shared**: Valid token holders can view
4.**Member**: Members in `sharedWith` array can view
5. ⚠️ **Token Validation**: Check expiration
### Issues Found
#### ISSUE 1.1 - Token Validation Logic Missing Email Check
**Severity**: HIGH
In the shared privacy mode, the code validates token **but does NOT validate expiration for other access types**. It should also allow members with valid emails to access.
**Current Code**:
```javascript
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid)
```
**Problem**: For `member` privacy, it checks `tour.sharedEmails` but never validates if they're in the `member` list during token validation.
**Recommendation**: Add explicit token handling for member access patterns.
---
## 2. GET /api/tours - Tour List Endpoint
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L128-L167)
```javascript
router.get('/', optionalAuth, async (req, res) => {
let query = { privacy: 'public' };
if (req.user && req.user.role !== 'guest') {
query = {
$or: [
{ privacy: 'public' },
{ createdBy: req.user._id },
{ privacy: 'member', sharedWith: req.user._id },
{ privacy: 'member', sharedEmails: req.user.email }
]
};
}
const tours = await Tour.find(query)...
});
```
### Expected Behavior
1. **Public Tours**: Visible to everyone (guest/logged in)
2. **Private Tours**: Only visible to creator
3. **Shared Tours**: Only visible if token provided **or user has permission**
4. **Member Tours**: Only visible to members in `sharedWith` or `sharedEmails`
### Issues Found
#### ISSUE 2.1 - Shared Token Tours Not Returned in List
**Severity**: CRITICAL
The endpoint **does NOT support token-based access**. If a guest has a shared link token, they cannot see the tour in the list.
**Current Code**: No `token` parameter handling
**Expected Behavior**: Should accept `?token=xxx` and return tours matching that token
**Problem Scenario**:
1. User A creates private tour with shareToken
2. User A shares token with Guest
3. Guest tries `GET /api/tours?token=xxx` → Empty list returned
4. Guest **cannot discover the tour exists**
**Recommendation**:
```javascript
// Add token support
if (req.query.token) {
const tourWithToken = await Tour.findOne({
shareToken: req.query.token,
$or: [
{ shareTokenExpires: null },
{ shareTokenExpires: { $gt: new Date() } }
]
});
if (tourWithToken) {
query = { _id: tourWithToken._id };
}
}
```
#### ISSUE 2.2 - Private Tours Accessible to Creator Only Not Enforced
**Severity**: MEDIUM
The query allows creator to see private tours, but **doesn't explicitly exclude non-creator guests** from seeing **someone else's shared tours**.
The current logic returns:
- Public tours → OK
- Creator's own tours (all privacy levels) → OK
- Member tours where user is a member → OK
But **missing**: Explicit rejection of non-creator access to private/shared tours they're not invited to.
---
## 3. GET /api/scenes/:id - Scene Detail Endpoint
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L149-L210)
```javascript
router.get('/:id', optionalAuth, async (req, res) => {
const scene = await Scene.findById(req.params.id)
.populate('createdBy', 'username')
.populate('tourId'); // ⚠️ Critical: Must populate tour for privacy checks
const tour = scene.tourId;
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) ||
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
));
if (!hasAccess) return res.status(403).json({ message: 'Unauthorized' });
// Increment views
if (!isOwner && !isAdmin) {
scene.views = (scene.views || 0) + 1;
await scene.save();
}
res.json(scene);
});
```
### Expected Behavior
Privacy should be inherited from Tour at minimum, with Scene-level overrides possible.
### Issues Found
#### ISSUE 3.1 - CRITICAL: Scene Model Missing Privacy Field
**Severity**: CRITICAL 🔴
**File**: [backend/models/Scene.js](backend/models/Scene.js)
**Current Schema**:
```javascript
const sceneSchema = new mongoose.Schema({
tourId: { type: ObjectId, required: true },
name: String,
shareToken: String,
shareTokenExpires: Date,
sharedWith: [ObjectId],
sharedEmails: [String],
// ❌ MISSING: privacy field
}, { timestamps: true });
```
**Problem**: The code references `scene.privacy` but it's **never defined in the schema**. MongoDB will store it but it won't be validated or properly indexed.
**Evidence**:
- Line 165: `isSceneTokenValid = scene.shareToken && ...` (assuming privacy exists)
- Line 170: `(scene.privacy === 'shared' && req.query.token === scene.shareToken ...)`
- But schema never defines `privacy` field
**Impact**:
- ❌ Scene privacy cannot be properly validated
- ❌ No default value enforcement
- ❌ No enum restrictions (could be any string)
- ❌ Causes logic errors when privacy is undefined
**Required Fix**:
```javascript
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
},
```
#### ISSUE 3.2 - Bridge Access Logic Allows Unintended Navigation
**Severity**: HIGH
```javascript
// [BRIDGE ACCESS LOGIC] Lines 194-205
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({
target_scene_id: scene._id
}).distinct('parent_scene_id');
if (potentialParents.length > 0) {
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) hasAccess = true;
}
}
```
**Problem**: This logic allows a guest with a shared link to ANY scene in a chain to navigate through ALL connected scenes.
**Scenario**:
1. User creates 5 linked scenes in a tour
2. Only scene 1 is shared (has token)
3. Guest with token for scene 1 can navigate to scene 2, 3, 4, 5 (if they know the hotspot coordinates)
4. **Scenes 2-5 may have different privacy settings but are still accessible**
**Recommendation**: Add privacy validation to ensure the target scene matches the token's privacy context.
---
## 4. GET /api/scenes - Scene List Endpoint
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L108-L142)
```javascript
router.get('/', optionalAuth, async (req, res) => {
const publicTours = await Tour.find({ privacy: 'public' }).select('_id');
const publicTourIds = publicTours.map(t => t._id);
let baseQuery = req.user && req.user.role !== 'guest'
? { $or: [
{ privacy: 'public' },
{ tourId: { $in: publicTourIds } },
{ createdBy: req.user._id },
{ sharedWith: req.user._id },
{ sharedEmails: req.user.email }
]}
: { $or: [
{ privacy: 'public' },
{ tourId: { $in: publicTourIds } }
]};
let finalQuery = baseQuery;
if (token) {
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
finalQuery = {
$or: [
baseQuery,
{ shareToken: token },
{ tourId: tourWithToken ? tourWithToken._id : null }
]
};
}
const scenes = await Scene.find(finalQuery)...
});
```
### Expected Behavior
- **Public Scenes**: Everyone sees
- **Private Scenes**: Only creator
- **Shared Scenes**: Only token holders + creator
- **Member Scenes**: Only members + creator
- **Tours**: Inherit parent tour privacy
### Issues Found
#### ISSUE 4.1 - Private Tour Scenes Should Not Be Visible
**Severity**: HIGH
**Problem**: The query returns all scenes from public tours but **doesn't check individual scene privacy**.
**Current Logic**:
```javascript
{ tourId: { $in: publicTourIds } } // All scenes from public tours returned
```
**Scenario**:
1. Tour A is public (privacy: 'public')
2. Scene 1 in Tour A created by User X (privacy: 'public')
3. Scene 2 in Tour A created by User Y (privacy: 'private')
4. Guest user calls GET /api/scenes
5. **Both scenes returned even though Scene 2 is marked private**
**Why This Matters**: Per ARCHITEC.md, even in a public tour, individual scenes can be private.
#### ISSUE 4.2 - Token Expiration Not Validated
**Severity**: MEDIUM
```javascript
if (token) {
const tourWithToken = await Tour.findOne({ shareToken: token }).select('_id');
// ❌ No check for shareTokenExpires
}
```
**Problem**: The code finds a tour with matching token but **never checks if token is expired**.
**Fix Required**:
```javascript
const tourWithToken = await Tour.findOne({
shareToken: token,
$or: [
{ shareTokenExpires: null },
{ shareTokenExpires: { $gt: new Date() } }
]
});
```
#### ISSUE 4.3 - Null Privacy Values Break Logic
**Severity**: MEDIUM
Scenes created before the privacy field was added will have `privacy: undefined`. The query checks:
```javascript
{ privacy: 'public' } // Won't match undefined!
```
**Scenario**:
1. Old scene exists with no privacy field
2. Scene is in public tour
3. Guest queries GET /api/scenes
4. Old scenes NOT returned (should be visible due to public tour)
---
## 5. PUT /api/tours/:id - Update Tour
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L46-L80)
```javascript
router.put('/:id', protect, async (req, res) => {
const tour = await Tour.findById(req.params.id);
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Not authorized' });
}
// Update privacy
if (privacy) tour.privacy = privacy;
// Handle token logic...
if (tour.privacy === 'shared') {
if (!tour.shareToken) tour.shareToken = crypto.randomBytes(24).toString('hex');
// ... handle expiration
} else {
tour.shareToken = undefined;
tour.shareTokenExpires = undefined;
}
// Propagate to scenes
await propagateScenePrivacy(tour._id, { privacy: tour.privacy, ... }, req.user._id);
res.json(tour);
});
```
### Expected Behavior
**Correctly Implemented** - Ownership check works, privacy propagation to scenes applied.
### Verified Working
- ✅ Checks creator ownership
- ✅ Generates/removes tokens based on privacy mode
- ✅ Handles token expiration
- ✅ Propagates privacy to all scenes in tour
---
## 6. PUT /api/scenes/:id - Update Scene
### Current Implementation
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L214-L280)
```javascript
router.put('/:id', protect, uploadSinglePanorama, async (req, res) => {
const scene = await Scene.findById(req.params.id);
if (!scene || (scene.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin')) {
return res.status(403).json({ message: 'Not authorized' });
}
// [SECURITY] Block direct privacy changes on Scene
if (privacy && privacy !== scene.privacy) {
return res.status(403).json({
message: "Privacy must be managed at Tour level"
});
}
// Update metadata only
scene.name = title || scene.name;
scene.description = description !== undefined ? description : scene.description;
await scene.save();
res.json(scene);
});
```
### Expected Behavior
**Correctly Implemented** - Privacy cannot be changed directly; must go through Tour update.
### Verified Working
- ✅ Checks creator ownership
- ✅ Blocks direct privacy modification
- ✅ Enforces Tour-level privacy management
---
## 7. DELETE /api/tours/:id & DELETE /api/scenes/:id
### Current Implementation
#### Tour Deletion
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L168-L190)
```javascript
router.delete('/:id', protect, async (req, res) => {
const tour = await Tour.findById(req.params.id);
if (tour.createdBy.toString() !== req.user._id.toString() && req.user.role !== 'admin') {
return res.status(403).json({ message: 'Not authorized' });
}
// Delete all scenes in tour
const scenesInTour = await Scene.find({ tourId: tour._id });
for (const scene of scenesInTour) {
await deleteSceneCascade(scene._id, req.user._id);
}
await Tour.findByIdAndDelete(req.params.id);
res.json({ message: 'Tour deleted' });
});
```
#### Scene Deletion
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L303-L350)
```javascript
router.delete('/:id', protect, async (req, res) => {
const rootScene = await Scene.findById(rootSceneId);
if (req.user.role !== 'admin' && rootScene.createdBy.toString() !== req.user._id.toString()) {
return res.status(403).json({ message: 'Forbidden' });
}
// Cascade delete
await deleteSceneCascade(rootSceneId, req.user._id);
// Update tour (remove from scenes array, update rootSceneId)
if (tourId) {
const tour = await Tour.findById(tourId);
if (tour) {
tour.scenes = tour.scenes.filter(sId => sId.toString() !== rootSceneId.toString());
if (tour.rootSceneId?.toString() === rootSceneId.toString()) {
tour.rootSceneId = tour.scenes.length > 0 ? tour.scenes[0] : null;
}
// Delete tour if empty
const actualRemainingScenes = await Scene.countDocuments({ tourId: tour._id });
if (actualRemainingScenes === 0) {
await Tour.findByIdAndDelete(tour._id);
return res.json({ message: 'Tour deleted' });
}
await tour.save();
}
}
res.json({ message: 'Scene deleted' });
});
```
### Expected Behavior
**Correctly Implemented** - Delete permissions properly checked.
### Verified Working
- ✅ Creator-only deletion
- ✅ Cascade delete scenes when tour deleted
- ✅ Cleanup tour references when scene deleted
- ✅ Auto-delete empty tours
---
## 8. Image Asset Streaming - GET /api/assets/view/:assetId
### Current Implementation
**File**: [backend/routes/assetRoutes.js](backend/routes/assetRoutes.js#L18-L100)
```javascript
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
const asset = await Asset.findById(req.params.assetId);
// Complex privacy check
const scene = await Scene.findOne({ assetId: asset._id }).populate('tourId');
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour && tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = isAdmin ||
scene.privacy === 'public' ||
(tour && tour.privacy === 'public') ||
(scene.privacy === 'member' && userIdStr && (scene.sharedWith.some(...) || scene.sharedEmails.includes(...))) ||
isOwner ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour && tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid);
if (!hasAccess) return res.status(403).json({ message: 'Forbidden' });
res.sendFile(resolvedPath, { maxAge: 2592000000 });
});
```
### Issues Found
#### ISSUE 8.1 - Scene Privacy Field Again Missing
**Severity**: CRITICAL
This endpoint checks `scene.privacy === 'public'` and `scene.privacy === 'member'` but Scene model doesn't define the privacy field.
**Compounded by**: Scenes created with null privacy will fail these checks even if they should be public.
#### ISSUE 8.2 - Bridge Access Logic Not Applied
**Severity**: MEDIUM
Unlike the scene detail endpoint, this doesn't implement the bridge access logic. A guest with a shared scene token cannot navigate to connected scenes' assets.
---
## 9. Frontend Tour Display Logic
### Current Implementation
**File**: [frontend/js/main_map.js](frontend/js/main_map.js#L1468-L1535)
```javascript
async function loadScenes(urlToken = null) {
let url = `${API_BASE_URL}/scenes?_=${new Date().getTime()}`;
if (urlToken) url += `&token=${urlToken}`;
const response = await fetch(url, { headers });
const scenes = await response.json();
// Deduplicate by coordinates
scenes.forEach((scene) => {
const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`;
if (seenCoordinates.has(coordKey)) return; // Skip duplicates
// Add marker to map
const marker = L.marker([latNum, lngNum], { icon: calloutIcon });
marker.on('click', () => {
openScene(scene._id, scene.privacy, scene.shareToken || '');
});
});
}
```
### Expected Behavior
Only display scenes the user has permission to view.
### Issues Found
#### ISSUE 9.1 - Frontend Trust Issue
**Severity**: MEDIUM
The frontend blindly trusts backend to filter scenes by privacy. While this is standard practice, the logic doesn't provide additional validation.
**Current Flow**:
1. Frontend calls `GET /api/scenes`
2. Backend filters by privacy
3. Frontend displays all returned scenes
4. User clicks → `openScene(scene._id, scene.privacy, scene.shareToken)`
**Potential Issue**: If backend filtering is broken, frontend has no fallback.
**Recommendation**: Add client-side privacy display indicators (e.g., 🔒 icon for private scenes user cannot access).
#### ISSUE 9.2 - Tour Privacy Not Shown
**Severity**: LOW
Frontend displays scene privacy but not tour privacy. Users don't know if scenes are part of a private/shared tour.
**Recommendation**: Display tour privacy icon alongside scene marker.
---
## 10. Shared Link Token Validation
### Current Implementation
#### Token Generation (Tour Creation)
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L12-L35)
```javascript
const newTour = new Tour({
privacy: privacy || 'private',
shareToken: (privacy === 'shared') ? crypto.randomBytes(24).toString('hex') : undefined
});
if (newTour.privacy === 'shared') {
const expires = new Date();
expires.setDate(expires.getDate() + 7); // Default 7 days
newTour.shareTokenExpires = expires;
}
```
**Status**: ✅ Correctly implemented with default 7-day expiration
#### Token Validation (Multiple Endpoints)
**Pattern Used**:
```javascript
const isTokenValid = tour.shareToken &&
(!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
if (tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid)
// Grant access
```
**Issues**:
#### ISSUE 10.1 - Token Comparison Uses Weak Equality
**Severity**: MEDIUM
All token comparisons use `===` (strict equality) which is good, but **no case normalization or encoding validation**.
**Potential Issue**:
- Token with uppercase/lowercase differences won't match (probably fine)
- Token with URL encoding might not match (real issue)
**Scenario**:
1. User gets token from URL: `?token=abc%2Bdef` (URL encoded +)
2. Backend receives: `abc+def` (after URL decoding)
3. But comparison happens against raw token stored: might have encoding differences
**Recommendation**: Always decode and normalize tokens before comparison.
#### ISSUE 10.2 - Token Not Revoked on Privacy Change
**Severity**: MEDIUM
When a tour changes from shared → private, the code sets `tour.shareToken = undefined`. But:
```javascript
tour.shareToken = undefined; // Mongoose behavior: removes field
```
**Potential Issue**: Existing tokens might still work if there's a race condition or if clients cache the token.
**Better Approach**: Should explicitly null the token and add revocation timestamp.
---
## 11. Cross-Tour Link Security
### Current Implementation
When a guest with a shared token navigates through hotspots:
**File**: [backend/routes/sceneRoutes.js](backend/routes/sceneRoutes.js#L194-L205)
```javascript
// Bridge access logic
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id });
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token
});
if (authorizedParentExists) hasAccess = true;
}
```
### Issues Found
#### ISSUE 11.1 - Bridge Access Doesn't Validate Cross-Tour Navigation
**Severity**: HIGH
This allows guests to navigate from a shared scene in Tour A to a scene in Tour B if they're hotspot-linked, **even if Tour B is private**.
**Scenario**:
1. User A creates private Tour A with shared Scene 1
2. User B creates private Tour B with Scene 2
3. User A adds hotspot link from Scene 1 → Scene 2 (cross-tour link)
4. Guest with token for Scene 1 can now see Scene 2 **even though Tour B is private**
**Root Cause**: Bridge logic doesn't check target scene's tour privacy
**Recommendation**:
```javascript
// When validating bridge access, also check target tour privacy
if (authorizedParentExists) {
// Check if target scene's tour is accessible
const targetTour = await Tour.findById(scene.tourId);
if (targetTour.privacy === 'public' || targetTour.createdBy === req.user._id) {
hasAccess = true;
}
// Only grant access if target tour is public or owned by user
}
```
---
## 12. Member Access Email Validation
### Current Implementation
**File**: [backend/middlewares/TourController.js](backend/middlewares/TourController.js#L110-L115)
```javascript
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail))
))
```
### Issues Found
#### ISSUE 12.1 - Email Case Sensitivity
**Severity**: LOW
Email comparison is **case-sensitive** but emails should be case-insensitive.
**Scenario**:
1. Tour shared with: `User@Example.com`
2. User logs in with: `user@example.com`
3. Access **denied** (incorrect)
**Fix Required**:
```javascript
tour.sharedEmails.map(e => e.toLowerCase()).includes(userEmail.toLowerCase())
```
#### ISSUE 12.2 - Empty Email Bypass
**Severity**: MEDIUM
If user exists but has no email, the check might bypass:
```javascript
userEmail && tour.sharedEmails.includes(userEmail)
```
This is actually safe (requires non-empty email), but unclear.
---
## Summary Table
| # | Issue | File | Severity | Type | Impact |
|----|-------|------|----------|------|--------|
| 1.1 | Token validation missing email check | TourController.js | HIGH | Logic | Member access inconsistent |
| 2.1 | Shared tokens not returned in list | TourController.js | CRITICAL | Feature | Guests cannot discover shared tours |
| 2.2 | Private tour filtering unclear | TourController.js | MEDIUM | Logic | Edge case in permission logic |
| 3.1 | **Scene model missing privacy field** | Scene.js | CRITICAL | Schema | Core data structure broken |
| 3.2 | Bridge access too permissive | sceneRoutes.js | HIGH | Security | Cross-tour access bypass |
| 4.1 | Individual scene privacy not checked | sceneRoutes.js | HIGH | Logic | Private scenes visible in public tour |
| 4.2 | Token expiration not validated | sceneRoutes.js | MEDIUM | Logic | Expired tokens still work |
| 4.3 | Null privacy values ignored | sceneRoutes.js | MEDIUM | Data | Old scenes not found |
| 8.1 | Asset endpoint uses undefined privacy | assetRoutes.js | CRITICAL | Schema | Images serve to wrong users |
| 8.2 | Asset bridge access not implemented | assetRoutes.js | MEDIUM | Feature | Incomplete implementation |
| 9.1 | Frontend trust issue | main_map.js | MEDIUM | UX | No validation fallback |
| 9.2 | Tour privacy not displayed | main_map.js | LOW | UX | Information missing |
| 10.2 | Token revocation incomplete | TourController.js | MEDIUM | Logic | Old tokens might work |
| 11.1 | Cross-tour bridge access insecure | sceneRoutes.js | HIGH | Security | Private tours accessible |
| 12.1 | Email case sensitivity | TourController.js | LOW | Logic | Email-based access fails |
---
## Recommendations by Priority
### CRITICAL (Fix Immediately)
1. **Add `privacy` field to Scene schema**
```javascript
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
}
```
2. **Migrate existing scenes** to have proper privacy values (inherit from tour if not set)
3. **Add token support to GET /api/tours**
```javascript
if (req.query.token) {
// Find tour with matching token and valid expiration
}
```
4. **Fix scene privacy list endpoint** to check individual scene privacy, not just tour
### HIGH PRIORITY (Fix This Week)
5. **Validate token expiration in GET /api/scenes**
- Add `shareTokenExpires` check to token query
6. **Secure bridge access logic**
- Check target tour privacy before allowing cross-tour navigation
- Apply same logic to asset streaming
7. **Validate individual scene privacy** in public tours
- Don't assume all scenes in public tour are public
### MEDIUM PRIORITY (Fix This Sprint)
8. **Token revocation**
- Keep history of revoked tokens
- Add timestamp field
9. **Token parameter normalization**
- Validate and normalize before comparison
- Handle URL encoding properly
10. **Email case-insensitivity**
- Convert to lowercase before comparison
11. **Frontend privacy indicators**
- Show lock icons for private scenes
- Show tour privacy on markers
### LOW PRIORITY (Nice to Have)
12. Add comprehensive logging for privacy access decisions
13. Add audit trail for privacy changes
14. Add privacy migration tool for legacy data
---
## Testing Recommendations
### Test Scenarios
```javascript
// Public tour access
✓ Guest sees public tour scenes
✓ Guest can view public tour details
// Private tour access
✓ Creator sees own private tour
✓ Guest cannot see private tour
✓ Non-creator user cannot see private tour
// Shared token access
✓ Guest with valid token sees shared tour
✓ Guest with expired token denied access
✓ Guest with invalid token denied access
✓ Guest with shared token cannot access private tours via hotlinks
// Member access
✓ User in sharedWith list can access member tour
✓ User with matching email can access member tour
✓ Case-insensitive email matching works
✓ Non-member user cannot access member tour
// Cross-tour linking
✓ Hotspot link respects target tour privacy
✓ Bridge access validates expiration
✓ Bridge access doesn't bypass tour privacy
// Asset streaming
✓ Image available to authorized users
✓ Image denied to unauthorized users
✓ Token validation works for assets
✓ Watermarking respects privacy
```
---
## Conclusion
The project has a **solid foundation** for privacy management at the Tour level with proper propagation to scenes. However, **critical structural issues** (missing Scene.privacy field) and **logic gaps** (token handling, bridge access security, individual scene privacy validation) compromise the implementation.
The good news: Most issues can be fixed without major refactoring. The bad news: The missing Scene.privacy field requires a data migration.
**Recommended Action**:
1. Add Scene.privacy field to schema immediately
2. Migrate existing data
3. Apply the fix recommendations in priority order
4. Run comprehensive security tests
+43 -7
View File
@@ -142,13 +142,29 @@ router.get('/:id', optionalAuth, async (req, res) => {
const isAdmin = req.user && req.user.role === 'admin'; const isAdmin = req.user && req.user.role === 'admin';
const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires); const isTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
const userEmail = req.user ? req.user.email : null; const userEmail = req.user ? req.user.email : null;
const token = req.query.token;
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin || // [Security] Check permissions based on tour privacy
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTokenValid) || let hasAccess = tour.privacy === 'public' || isOwner || isAdmin;
(tour.privacy === 'member' && req.user && req.user._id && (
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) || // Shared link access - check token validity
(userEmail && tour.sharedEmails.includes(userEmail)) if (!hasAccess && tour.privacy === 'shared' && token && isTokenValid) {
)); hasAccess = token === tour.shareToken;
}
// Member access - check if user is in sharedWith or sharedEmails
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest') {
hasAccess = true;
}
// Fallback for specific shared members
if (!hasAccess && tour.privacy === 'member' && req.user && req.user._id) {
hasAccess = tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.some(email => email.toLowerCase() === userEmail.toLowerCase()));
}
// Private tours - only owner and admin
// (hasAccess already set above if owner/admin)
if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' }); if (!hasAccess) return res.status(403).json({ message: 'Bạn không có quyền truy cập Tour này.' });
@@ -159,16 +175,18 @@ router.get('/:id', optionalAuth, async (req, res) => {
}); });
// @route GET /api/tours // @route GET /api/tours
// @desc Lấy danh sách Tour công khai hoặc của chính mình // @desc Lấy danh sách Tour công khai, member, shared (với token), hoặc của chính mình
// @access Public/Private // @access Public/Private
router.get('/', optionalAuth, async (req, res) => { router.get('/', optionalAuth, async (req, res) => {
try { try {
const { token } = req.query;
let query = { privacy: 'public' }; let query = { privacy: 'public' };
if (req.user && req.user.role !== 'guest') { if (req.user && req.user.role !== 'guest') {
query = { query = {
$or: [ $or: [
{ privacy: 'public' }, { privacy: 'public' },
{ privacy: 'member' },
{ createdBy: req.user._id }, { createdBy: req.user._id },
{ privacy: 'member', sharedWith: req.user._id }, { privacy: 'member', sharedWith: req.user._id },
{ privacy: 'member', sharedEmails: req.user.email } { privacy: 'member', sharedEmails: req.user.email }
@@ -176,6 +194,24 @@ router.get('/', optionalAuth, async (req, res) => {
}; };
} }
// [Task 4.1] Support shared tours via token for guests
if (token) {
const tourWithToken = await Tour.findOne({
shareToken: token,
privacy: 'shared',
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
}).select('_id');
if (tourWithToken) {
query = {
$or: [
query,
{ _id: tourWithToken._id }
]
};
}
}
const tours = await Tour.find(query) const tours = await Tour.find(query)
.populate('createdBy', 'username') .populate('createdBy', 'username')
.populate({ .populate({
+5
View File
@@ -39,6 +39,11 @@ const sceneSchema = new mongoose.Schema({
enum: ['processing', 'completed', 'failed'], enum: ['processing', 'completed', 'failed'],
default: 'processing' default: 'processing'
}, },
privacy: {
type: String,
enum: ['public', 'private', 'member', 'shared'],
default: 'private'
},
shareToken: String, shareToken: String,
shareTokenExpires: Date, shareTokenExpires: Date,
sharedWith: [{ sharedWith: [{
+6 -1
View File
@@ -24,6 +24,8 @@ const uploadDir = process.env.UPLOAD_DIR
*/ */
router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => { router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res) => {
try { try {
console.log(`[AssetView] Đang yêu cầu hiển thị Asset: ${req.params.assetId}. Token: ${req.query.token ? 'Có' : 'Không'}`);
const asset = await Asset.findById(req.params.assetId); const asset = await Asset.findById(req.params.assetId);
if (!asset) return res.status(404).json({ message: 'Asset not found' }); if (!asset) return res.status(404).json({ message: 'Asset not found' });
@@ -88,7 +90,10 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
} }
} }
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' }); if (!hasAccess) {
console.warn(`[AssetView Denied] Không có quyền xem ảnh cho Asset ${asset._id}`);
return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' });
}
} }
// Kiểm tra file vật lý (Giai đoạn 2 - Async) // Kiểm tra file vật lý (Giai đoạn 2 - Async)
+34 -2
View File
@@ -2,15 +2,47 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const Hotspot = require('../models/Hotspot'); const Hotspot = require('../models/Hotspot');
const Scene = require('../models/Scene'); const Scene = require('../models/Scene');
const { protect } = require('../middlewares/authMiddleware'); const { protect, optionalAuth } = require('../middlewares/authMiddleware');
const { calculateReverseYaw } = require('../utils/hotspotHelper'); const { calculateReverseYaw } = require('../utils/hotspotHelper');
const Tour = require('../models/Tour');
/** /**
* @route GET /api/hotspots/:scene_id * @route GET /api/hotspots/:scene_id
* @desc Lấy toàn bộ danh sách hotspot của một cảnh * @desc Lấy toàn bộ danh sách hotspot của một cảnh
*/ */
router.get('/:scene_id', async (req, res) => { router.get('/:scene_id', optionalAuth, async (req, res) => {
try { try {
// [SECURITY] Kiểm tra quyền xem hotspots dựa trên quyền truy cập cảnh cha
const scene = await Scene.findById(req.params.scene_id).populate('tourId');
if (!scene) return res.status(404).json({ message: 'Scene not found' });
const tour = scene.tourId;
const isOwner = req.user && req.user._id && tour?.createdBy?.toString() === req.user._id.toString();
const isAdmin = req.user && req.user.role === 'admin';
const isSceneTokenValid = scene.shareToken && (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires);
const isTourTokenValid = tour?.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
let hasAccess = (tour?.privacy === 'public') || (scene.privacy === 'public') || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) ||
(tour?.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) ||
(tour?.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest');
// Bridge Access cho Hotspots
if (!hasAccess && req.query.token) {
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
const authorizedParentExists = await Scene.exists({
_id: { $in: potentialParents },
shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
});
if (authorizedParentExists) hasAccess = true;
}
if (!hasAccess) {
console.warn(`[Backend-Hotspots-Denied] Scene: ${req.params.scene_id}`);
return res.status(403).json({ message: 'Bạn không có quyền xem các điểm điều hướng của cảnh này.' });
}
const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id }) const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id })
.populate({ .populate({
path: 'target_scene_id', path: 'target_scene_id',
+28 -6
View File
@@ -117,7 +117,16 @@ router.get('/', optionalAuth, async (req, res) => {
// Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ // Quyền cơ bản: Công khai hoặc là chủ sở hữu/thành viên được chia sẻ
let baseQuery = req.user && req.user.role !== 'guest' let baseQuery = req.user && req.user.role !== 'guest'
? { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }, { createdBy: req.user._id }, { sharedWith: req.user._id }, { sharedEmails: req.user.email }] } ? {
$or: [
{ privacy: 'public' },
{ privacy: 'member' },
{ tourId: { $in: publicTourIds } },
{ createdBy: req.user._id },
{ sharedWith: req.user._id },
{ sharedEmails: req.user.email }
]
}
: { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }] }; : { $or: [{ privacy: 'public' }, { tourId: { $in: publicTourIds } }] };
let finalQuery = baseQuery; let finalQuery = baseQuery;
@@ -148,6 +157,8 @@ router.get('/', optionalAuth, async (req, res) => {
// @route GET /api/scenes/:id // @route GET /api/scenes/:id
router.get('/:id', optionalAuth, async (req, res) => { router.get('/:id', optionalAuth, async (req, res) => {
try { try {
console.log(`[Backend-Scene] Yêu cầu chi tiết: ${req.params.id}. User: ${req.user?._id || 'Guest'}, QueryToken: ${req.query.token || 'N/A'}`);
const scene = await Scene.findById(req.params.id) const scene = await Scene.findById(req.params.id)
.populate('createdBy', 'username') .populate('createdBy', 'username')
.populate('assetId') .populate('assetId')
@@ -165,18 +176,23 @@ router.get('/:id', optionalAuth, async (req, res) => {
const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires); const isTourTokenValid = tour.shareToken && (!tour.shareTokenExpires || new Date() < tour.shareTokenExpires);
const userEmail = req.user ? req.user.email : null; const userEmail = req.user ? req.user.email : null;
let hasAccess = tour.privacy === 'public' || isOwner || isAdmin || // [FIX] Cho phép truy cập nếu bản thân Scene CÔNG KHAI hoặc Tour CÔNG KHAI
let hasAccess = tour.privacy === 'public' || scene.privacy === 'public' || isOwner || isAdmin ||
(scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token (scene.privacy === 'shared' && req.query.token === scene.shareToken && isSceneTokenValid) || // Access via scene's token
(tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token (tour.privacy === 'shared' && req.query.token === tour.shareToken && isTourTokenValid) || // Access via tour's token
(tour.privacy === 'member' && req.user && req.user._id && ( // Access for members (tour.privacy === 'member' && req.user && req.user._id && req.user.role !== 'guest') || // Access for any logged-in member
(tour.privacy === 'member' && req.user && req.user._id && ( // Specific shared members (legacy support)
tour.sharedWith.some(u => u.toString() === req.user._id.toString()) || tour.sharedWith.some(u => u.toString() === req.user._id.toString()) ||
(userEmail && tour.sharedEmails.includes(userEmail)) (userEmail && tour.sharedEmails.includes(userEmail))
)); ));
if (req.query.token) {
console.log(`[Backend-Auth] Token: ${req.query.token}. Match Scene: ${req.query.token === scene.shareToken}, Match Tour: ${req.query.token === tour.shareToken}, Access: ${hasAccess}`);
}
// [BRIDGE ACCESS LOGIC] // [BRIDGE ACCESS LOGIC]
// Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không // Nếu chưa có quyền, kiểm tra xem người dùng có đến từ một cảnh hợp lệ thuộc Tour khác không
if (!hasAccess && req.query.token) { if (!hasAccess && req.query.token) {
// Tìm tất cả các cảnh (parent) có hotspot trỏ đến cảnh hiện tại
const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id'); const potentialParents = await Hotspot.find({ target_scene_id: scene._id }).distinct('parent_scene_id');
if (potentialParents.length > 0) { if (potentialParents.length > 0) {
// Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không // Kiểm tra xem có cảnh cha nào sở hữu shareToken này và còn hạn không
@@ -185,11 +201,17 @@ router.get('/:id', optionalAuth, async (req, res) => {
shareToken: req.query.token, shareToken: req.query.token,
$or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }] $or: [{ shareTokenExpires: null }, { shareTokenExpires: { $gt: new Date() } }]
}); });
if (authorizedParentExists) hasAccess = true; if (authorizedParentExists) {
hasAccess = true;
console.log(`[Backend-Bridge] Quyền được chấp thuận qua Scene cha.`);
}
} }
} }
if (!hasAccess) return res.status(403).json({ message: 'Bạn không được phép di chuyển đến cảnh này' }); if (!hasAccess) {
console.warn(`[Backend-Denied] Scene: ${scene._id}, TourPrivacy: ${tour.privacy}, ScenePrivacy: ${scene.privacy}`);
return res.status(403).json({ message: 'Bạn không có quyền truy cập cảnh này.' });
}
// Increment view count if not owner/admin and not a bot // Increment view count if not owner/admin and not a bot
if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) { if (!isOwner && !isAdmin && !req.headers['user-agent']?.match(/bot|crawl|spider/i)) {
+16 -31
View File
@@ -993,14 +993,6 @@ async function loadScenes(urlToken = null) {
return; return;
} }
// 2. Logic lọc Ảnh mẹ: Sửa lỗi typo coordKey (dùng latNum 2 lần)
const coordKey = `${latNum.toFixed(6)},${lngNum.toFixed(6)}`;
if (seenCoordinates.has(coordKey)) {
console.log(`[Frontend] Bỏ qua Scene "${scene.name || scene.title}" (ID: ${scene._id}) do trùng tọa độ (hotspot con).`);
return;
}
seenCoordinates.add(coordKey);
// 3. Truy cập Asset an toàn // 3. Truy cập Asset an toàn
const assetId = scene.assetId?._id || scene.assetId; const assetId = scene.assetId?._id || scene.assetId;
if (!assetId) return; // Bỏ qua nếu không có ảnh liên kết if (!assetId) return; // Bỏ qua nếu không có ảnh liên kết
@@ -1306,11 +1298,10 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
console.log(`[Viewer] Đang mở scene: ${sceneId}`); console.log(`[Frontend-Viewer] Đang nạp Scene: ${sceneId}. Token: ${shareToken || 'None'}`);
let url = `${API_BASE_URL}/scenes/${sceneId}`; const authParam = shareToken ? `?token=${shareToken}` : '';
if (privacy === 'shared' && shareToken) { let url = `${API_BASE_URL}/scenes/${sceneId}${authParam}`;
url += `?token=${shareToken}`; let hotspotsUrl = `${API_BASE_URL}/hotspots/${sceneId}${authParam}`;
}
// Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang // Lưu trạng thái Scene hiện tại để khôi phục sau khi reload trang
localStorage.setItem('activeSceneId', sceneId); localStorage.setItem('activeSceneId', sceneId);
@@ -1320,23 +1311,20 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
// Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng // Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng
const [sceneRes, hotspotsRes] = await Promise.all([ const [sceneRes, hotspotsRes] = await Promise.all([
fetch(url, { method: 'GET', headers }), fetch(url, { method: 'GET', headers }),
fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers }) fetch(hotspotsUrl, { method: 'GET', headers })
]); ]);
if (!sceneRes.ok) {
const errorData = await sceneRes.json();
console.error(`[Viewer API Error] Scene status: ${sceneRes.status}. Message: ${errorData.message}`);
throw new Error(errorData.message || 'Không thể tải thông tin cảnh');
}
console.log(`[Viewer API Success] Đã nạp xong Scene và Hotspots cho ${sceneId}`);
const scene = await sceneRes.json(); const scene = await sceneRes.json();
const hotspots = await hotspotsRes.json(); const hotspots = await hotspotsRes.json();
// Ngăn chặn mở Scene nếu ảnh chưa xử lý xong hoặc lỗi // Ngăn chặn mở Scene nếu ảnh chưa xử lý xong hoặc lỗi
if (scene.status === 'processing') {
showNotification("Cảnh này đang được nén chất lượng 8K. Vui lòng quay lại sau vài giây.", 'warning');
return;
}
if (scene.status === 'failed') {
showNotification("Lỗi xử lý ảnh 8K. Vui lòng upload lại ảnh cho cảnh này.", 'error');
return;
}
// [TOUR ID] Luôn cập nhật activeTourId theo Scene hiện tại để đảm bảo các cảnh con/hotspot mới
// được gán đúng vào Tour gốc của cảnh đang xem, tránh sử dụng ID cũ/lỗi từ phiên trước. // được gán đúng vào Tour gốc của cảnh đang xem, tránh sử dụng ID cũ/lỗi từ phiên trước.
const openedSceneTourId = scene.tourId?._id || scene.tourId || scene._id; const openedSceneTourId = scene.tourId?._id || scene.tourId || scene._id;
localStorage.setItem('activeTourId', openedSceneTourId); localStorage.setItem('activeTourId', openedSceneTourId);
@@ -1351,10 +1339,6 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
const isOwner = currentUserId && sceneOwnerId && currentUserId.toString() === sceneOwnerId.toString(); const isOwner = currentUserId && sceneOwnerId && currentUserId.toString() === sceneOwnerId.toString();
const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu'; const isAdmin = userRole === 'admin' || userRole === 'Chủ sở hữu';
if (scene.privacy === 'private' && !isOwner && !isAdmin) {
throw new Error("Cảnh này đã được chủ sở hữu chuyển sang chế độ riêng tư.");
}
console.log("DEBUG: Hotspots raw data from API:", hotspots); console.log("DEBUG: Hotspots raw data from API:", hotspots);
// Tự động focus bản đồ vào vị trí của Scene // Tự động focus bản đồ vào vị trí của Scene
@@ -1406,10 +1390,11 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ) // Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('sceneId') || window.location.pathname.includes('/api/share/')) { if (urlParams.has('sceneId') || window.location.pathname.includes('/api/share/')) {
showNotification("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.", 'error'); // Sử dụng Error Modal để thông báo không bị biến mất đột ngột
// Xóa toàn bộ tham số URL và tải lại trang để làm mới trạng thái (về trang chủ dành cho khách) showErrorModal("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.", "Lỗi truy cập", "🚫");
// Chỉ xóa tham số URL để làm sạch thanh địa chỉ, không cần reload trang gây văng marker
window.history.replaceState({}, document.title, "/"); window.history.replaceState({}, document.title, "/");
location.reload();
return; return;
} }