skyview/assets/static/js/modules/map-manager.js
Ole-Morten Duesund 4a0a993e81 fix: Sanitize all innerHTML dynamic values to prevent XSS
Add centralized escapeHtml() utility and apply it to every dynamic value
inserted via innerHTML/template literals across the frontend. Data from
VRS JSON sources and external CSV files (airline names, countries) flows
through the backend as arbitrary strings that could contain HTML. While
Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those
escapes before the values reach innerHTML — enabling script injection.

Affected modules: aircraft-manager, ui-manager, callsign-manager,
map-manager, and the 3D radar labels in app.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:21:56 +01:00

387 lines
No EOL
14 KiB
JavaScript

// Map and visualization management module
import { escapeHtml } from './html-utils.js';
export class MapManager {
constructor() {
this.map = null;
this.coverageMap = null;
this.mapOrigin = null;
// Source markers and overlays
this.sourceMarkers = new Map();
this.rangeCircles = new Map();
this.showSources = true;
this.showRange = true;
this.selectedSource = null;
this.heatmapLayer = null;
// Data references
this.sourcesData = new Map();
// Map theme
this.isDarkMode = false;
this.currentTileLayer = null;
this.coverageTileLayer = null;
}
async initializeMap() {
// Get origin from server
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
try {
const response = await fetch('/api/origin');
if (response.ok) {
origin = await response.json();
}
} catch (error) {
console.warn('Could not fetch origin, using default:', error);
}
// Store origin for reset functionality
this.mapOrigin = origin;
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
// Light tile layer by default
this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}).addTo(this.map);
// Add scale control for distance estimation
L.control.scale({
metric: true,
imperial: true,
position: 'bottomright'
}).addTo(this.map);
return this.map;
}
async initializeCoverageMap() {
if (!this.coverageMap) {
// Get origin from server
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
try {
const response = await fetch('/api/origin');
if (response.ok) {
origin = await response.json();
}
} catch (error) {
console.warn('Could not fetch origin for coverage map, using default:', error);
}
this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.coverageMap);
// Add scale control for distance estimation
L.control.scale({
metric: true,
imperial: true,
position: 'bottomright'
}).addTo(this.coverageMap);
}
return this.coverageMap;
}
updateSourcesData(data) {
if (data.sources) {
this.sourcesData.clear();
data.sources.forEach(source => {
this.sourcesData.set(source.id, source);
});
}
}
updateSourceMarkers() {
if (!this.map || !this.showSources) return;
// Remove markers for sources that no longer exist
const currentSourceIds = new Set(this.sourcesData.keys());
for (const [id, marker] of this.sourceMarkers) {
if (!currentSourceIds.has(id)) {
this.map.removeLayer(marker);
this.sourceMarkers.delete(id);
}
}
// Update or create markers for current sources
for (const [id, source] of this.sourcesData) {
if (source.latitude && source.longitude) {
if (this.sourceMarkers.has(id)) {
// Update existing marker
const marker = this.sourceMarkers.get(id);
// Update marker style if status changed
marker.setStyle({
radius: source.active ? 10 : 6,
fillColor: source.active ? '#00d4ff' : '#666666',
fillOpacity: 0.8
});
// Update popup content if it's open
if (marker.isPopupOpen()) {
marker.setPopupContent(this.createSourcePopupContent(source));
}
} else {
// Create new marker
const marker = L.circleMarker([source.latitude, source.longitude], {
radius: source.active ? 10 : 6,
fillColor: source.active ? '#00d4ff' : '#666666',
color: '#ffffff',
weight: 2,
fillOpacity: 0.8,
className: 'source-marker'
}).addTo(this.map);
marker.bindPopup(this.createSourcePopupContent(source), {
maxWidth: 300
});
this.sourceMarkers.set(id, marker);
}
}
}
this.updateSourcesLegend();
}
updateRangeCircles() {
if (!this.map || !this.showRange) return;
// Clear existing circles
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
this.rangeCircles.clear();
// Add range circles for active sources
for (const [id, source] of this.sourcesData) {
if (source.active && source.latitude && source.longitude) {
// Add multiple range circles (50km, 100km, 200km)
const ranges = [50000, 100000, 200000];
ranges.forEach((range, index) => {
const circle = L.circle([source.latitude, source.longitude], {
radius: range,
fillColor: 'transparent',
color: '#00d4ff',
weight: 2,
opacity: 0.7 - (index * 0.15),
dashArray: '8,4'
}).addTo(this.map);
this.rangeCircles.set(`${id}_${range}`, circle);
});
}
}
}
createSourcePopupContent(source, aircraftData) {
const esc = escapeHtml;
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
return `
<div class="source-popup">
<h3>${esc(source.name)}</h3>
<p><strong>ID:</strong> ${esc(source.id)}</p>
<p><strong>Location:</strong> ${esc(source.latitude.toFixed(4))}°, ${esc(source.longitude.toFixed(4))}°</p>
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
<p><strong>Aircraft:</strong> ${esc(aircraftCount)}</p>
<p><strong>Messages:</strong> ${esc(source.messages || 0)}</p>
<p><strong>Last Seen:</strong> ${source.last_seen ? esc(new Date(source.last_seen).toLocaleString()) : 'N/A'}</p>
</div>
`;
}
updateSourcesLegend() {
const legend = document.getElementById('sources-legend');
if (!legend) return;
legend.innerHTML = '';
// Sort sources alphabetically by name
const sortedSources = Array.from(this.sourcesData.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
for (const source of sortedSources) {
const esc = escapeHtml;
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
<span title="${esc(source.host)}:${esc(source.port)}">${esc(source.name)}</span>
`;
legend.appendChild(item);
}
}
resetMap() {
if (this.mapOrigin && this.map) {
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
}
}
toggleRangeCircles() {
this.showRange = !this.showRange;
if (this.showRange) {
this.updateRangeCircles();
} else {
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
this.rangeCircles.clear();
}
return this.showRange;
}
toggleSources() {
this.showSources = !this.showSources;
if (this.showSources) {
this.updateSourceMarkers();
} else {
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
this.sourceMarkers.clear();
}
return this.showSources;
}
toggleDarkMode() {
this.isDarkMode = !this.isDarkMode;
const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const tileUrl = this.isDarkMode ? darkUrl : lightUrl;
const tileOptions = {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
};
// Update main map
if (this.map && this.currentTileLayer) {
this.map.removeLayer(this.currentTileLayer);
this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map);
}
// Update coverage map
if (this.coverageMap && this.coverageTileLayer) {
this.coverageMap.removeLayer(this.coverageTileLayer);
this.coverageTileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.coverageMap);
}
return this.isDarkMode;
}
// Coverage map methods
updateCoverageControls() {
const select = document.getElementById('coverage-source');
if (!select) return;
select.innerHTML = '<option value="">Select Source</option>';
for (const [id, source] of this.sourcesData) {
const option = document.createElement('option');
option.value = id;
option.textContent = source.name;
select.appendChild(option);
}
}
async updateCoverageDisplay() {
if (!this.selectedSource || !this.coverageMap) return;
try {
const response = await fetch(`/api/coverage/${this.selectedSource}`);
const data = await response.json();
// Clear existing coverage markers
this.coverageMap.eachLayer(layer => {
if (layer instanceof L.CircleMarker) {
this.coverageMap.removeLayer(layer);
}
});
// Add coverage points
data.points.forEach(point => {
const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
L.circleMarker([point.lat, point.lon], {
radius: 3,
fillColor: this.getSignalColor(point.signal),
color: 'white',
weight: 1,
fillOpacity: intensity
}).addTo(this.coverageMap);
});
} catch (error) {
console.error('Failed to load coverage data:', error);
}
}
async toggleHeatmap() {
if (!this.selectedSource) {
alert('Please select a source first');
return false;
}
if (this.heatmapLayer) {
this.coverageMap.removeLayer(this.heatmapLayer);
this.heatmapLayer = null;
return false;
} else {
try {
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
const data = await response.json();
// Create heatmap layer (simplified)
this.createHeatmapOverlay(data);
return true;
} catch (error) {
console.error('Failed to load heatmap data:', error);
return false;
}
}
}
getSignalColor(signal) {
if (signal > -10) return '#00ff88';
if (signal > -20) return '#ffff00';
if (signal > -30) return '#ff8c00';
return '#ff4444';
}
createHeatmapOverlay(data) {
// 🚧 Under Construction: Heatmap visualization not yet implemented
// Planned: Use Leaflet.heat library for proper heatmap rendering
console.log('Heatmap overlay requested but not yet implemented');
// Show user-visible notice
if (window.uiManager) {
window.uiManager.showError('Heatmap visualization is under construction 🚧');
}
}
setSelectedSource(sourceId) {
this.selectedSource = sourceId;
}
getSourcePositions() {
const positions = [];
for (const [id, source] of this.sourcesData) {
if (source.latitude && source.longitude) {
positions.push([source.latitude, source.longitude]);
}
}
return positions;
}
}