- Escape all innerHTML assignments with user/external data across 12 JS files - Upgrade credential encryption: per-value IV, key moved to sessionStorage - Fix open redirect in TOTP auth via proper URL hostname validation - Remove sensitive DNS topology data from localStorage cache - Add security regression test suite (51 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
9.1 KiB
JavaScript
230 lines
9.1 KiB
JavaScript
// ========== WEATHER WIDGET ==========
|
|
(function() {
|
|
// Inject modal HTML
|
|
injectModal('weather-modal', `<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
<label for="weather-location-input">Location:</label>
|
|
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
|
<div style="margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Units</label>
|
|
<div style="display: flex; gap: 8px;">
|
|
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="imperial"><span class="weather-unit-card">°F / mph</span></label>
|
|
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
|
</div>
|
|
</div>
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);
|
|
|
|
/* Weather Widget — powered by Open-Meteo (free, no API key) */
|
|
const LOCATION_KEY = 'weather-location';
|
|
const LEGACY_KEY = 'weather-zip';
|
|
const GEO_CACHE_KEY = 'weather-geo';
|
|
const UNIT_KEY = 'weather-unit';
|
|
|
|
// Migrate from old ZIP-only key
|
|
if (!safeGet(LOCATION_KEY) && safeGet(LEGACY_KEY)) {
|
|
safeSet(LOCATION_KEY, safeGet(LEGACY_KEY));
|
|
}
|
|
|
|
function getUnit() {
|
|
return safeGet(UNIT_KEY) || 'imperial';
|
|
}
|
|
|
|
function getWeatherElements() {
|
|
return {
|
|
icon: document.querySelector('.weather-icon'),
|
|
temp: document.querySelector('.weather-temp'),
|
|
condition: document.querySelector('.weather-condition'),
|
|
location: document.querySelector('.weather-location'),
|
|
wind: document.querySelector('.weather-wind')
|
|
};
|
|
}
|
|
|
|
// WMO Weather Code mapping
|
|
const wmoDescriptions = {
|
|
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
|
45: 'Fog', 48: 'Rime fog',
|
|
51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
|
|
56: 'Freezing drizzle', 57: 'Dense freezing drizzle',
|
|
61: 'Light rain', 63: 'Moderate rain', 65: 'Heavy rain',
|
|
66: 'Light freezing rain', 67: 'Heavy freezing rain',
|
|
71: 'Light snow', 73: 'Moderate snow', 75: 'Heavy snow', 77: 'Snow grains',
|
|
80: 'Light showers', 81: 'Moderate showers', 82: 'Violent showers',
|
|
85: 'Light snow showers', 86: 'Heavy snow showers',
|
|
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm'
|
|
};
|
|
|
|
const wmoIcons = {
|
|
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
|
|
45: '🌫️', 48: '🌫️',
|
|
51: '🌦️', 53: '🌦️', 55: '🌦️',
|
|
56: '🌨️', 57: '🌨️',
|
|
61: '🌦️', 63: '🌧️', 65: '🌧️',
|
|
66: '🌨️', 67: '🌨️',
|
|
71: '🌨️', 73: '❄️', 75: '❄️', 77: '❄️',
|
|
80: '🌦️', 81: '🌧️', 82: '🌧️',
|
|
85: '🌨️', 86: '❄️',
|
|
95: '⛈️', 96: '⛈️', 99: '⛈️'
|
|
};
|
|
|
|
// Wind direction from degrees
|
|
const windDirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
|
|
function degToDir(deg) {
|
|
return windDirs[Math.round(deg / 22.5) % 16];
|
|
}
|
|
|
|
// Geocode location via Open-Meteo, with localStorage cache
|
|
async function geocodeLocation(query) {
|
|
const cached = safeGet(GEO_CACHE_KEY);
|
|
if (cached) {
|
|
try {
|
|
const geo = JSON.parse(cached);
|
|
if (geo.query === query) return geo;
|
|
} catch {}
|
|
}
|
|
const resp = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=1&language=en&format=json`);
|
|
if (!resp.ok) throw new Error('Geocoding failed');
|
|
const data = await resp.json();
|
|
if (!data.results || !data.results.length) throw new Error('Location not found');
|
|
const r = data.results[0];
|
|
const geo = {
|
|
query,
|
|
lat: r.latitude,
|
|
lon: r.longitude,
|
|
city: r.name,
|
|
state: r.admin1 || '',
|
|
country: r.country || '',
|
|
countryCode: r.country_code || ''
|
|
};
|
|
safeSet(GEO_CACHE_KEY, JSON.stringify(geo));
|
|
return geo;
|
|
}
|
|
|
|
function formatLocation(geo) {
|
|
// For US locations show "City, State", for others show "City, Country"
|
|
if (geo.countryCode === 'US' && geo.state) {
|
|
return `${geo.city}, ${geo.state}`;
|
|
}
|
|
if (geo.country) {
|
|
return `${geo.city}, ${geo.country}`;
|
|
}
|
|
return geo.city;
|
|
}
|
|
|
|
async function fetchWeather(location) {
|
|
try {
|
|
const geo = await geocodeLocation(location);
|
|
const unit = getUnit();
|
|
const tempUnit = unit === 'metric' ? 'celsius' : 'fahrenheit';
|
|
const windUnit = unit === 'metric' ? 'kmh' : 'mph';
|
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${tempUnit}&wind_speed_unit=${windUnit}`;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) throw new Error('Weather fetch failed');
|
|
const data = await resp.json();
|
|
const c = data.current;
|
|
const code = c.weather_code;
|
|
return {
|
|
temp: Math.round(c.temperature_2m),
|
|
condition: wmoDescriptions[code] || 'Unknown',
|
|
icon: wmoIcons[code] || '🌤️',
|
|
locationStr: formatLocation(geo),
|
|
windSpeed: Math.round(c.wind_speed_10m),
|
|
windDir: degToDir(c.wind_direction_10m),
|
|
unit
|
|
};
|
|
} catch (error) {
|
|
console.warn('Weather fetch failed:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function updateWeather() {
|
|
const weatherWidget = getWeatherElements();
|
|
if (!weatherWidget.icon || !weatherWidget.temp || !weatherWidget.condition || !weatherWidget.location || !weatherWidget.wind) {
|
|
console.warn('Weather widget elements not found');
|
|
return;
|
|
}
|
|
|
|
const location = safeGet(LOCATION_KEY);
|
|
if (!location) {
|
|
weatherWidget.location.textContent = 'Set Location';
|
|
weatherWidget.temp.textContent = '--\u00B0';
|
|
weatherWidget.condition.textContent = 'Click \u2699\uFE0F to configure';
|
|
weatherWidget.wind.textContent = '--';
|
|
weatherWidget.icon.innerHTML = '<span class="weather-emoji">\uD83C\uDF24\uFE0F</span>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const weather = await fetchWeather(location);
|
|
if (weather) {
|
|
const tempSuffix = weather.unit === 'metric' ? '\u00B0C' : '\u00B0F';
|
|
const windLabel = weather.unit === 'metric' ? 'km/h' : 'mph';
|
|
weatherWidget.location.textContent = weather.locationStr;
|
|
weatherWidget.temp.textContent = `${weather.temp}${tempSuffix}`;
|
|
weatherWidget.condition.textContent = weather.condition;
|
|
weatherWidget.wind.textContent = `Wind: ${weather.windSpeed} ${windLabel} ${weather.windDir}`;
|
|
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${escapeHtml(weather.icon)}</span>`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Weather update error:', error);
|
|
weatherWidget.location.textContent = 'Weather Error';
|
|
weatherWidget.temp.textContent = 'Error';
|
|
weatherWidget.condition.textContent = 'Failed to load';
|
|
weatherWidget.wind.textContent = '--';
|
|
}
|
|
}
|
|
|
|
// Weather settings modal
|
|
const modal = document.getElementById('weather-modal');
|
|
const locationInput = document.getElementById('weather-location-input');
|
|
|
|
document.getElementById('weather-settings')?.addEventListener('click', () => {
|
|
locationInput.value = safeGet(LOCATION_KEY) || '';
|
|
// Set unit radio
|
|
const unit = getUnit();
|
|
const radio = modal.querySelector(`input[name="weather-unit-radio"][value="${unit}"]`);
|
|
if (radio) radio.checked = true;
|
|
modal.classList.add('show');
|
|
locationInput.focus();
|
|
});
|
|
|
|
document.getElementById('weather-cancel')?.addEventListener('click', () => {
|
|
modal.classList.remove('show');
|
|
});
|
|
|
|
document.getElementById('weather-save')?.addEventListener('click', () => {
|
|
const loc = locationInput.value.trim();
|
|
if (loc) {
|
|
// Clear geo cache when location changes so it re-geocodes
|
|
const oldLoc = safeGet(LOCATION_KEY);
|
|
if (oldLoc !== loc) safeSet(GEO_CACHE_KEY, '');
|
|
safeSet(LOCATION_KEY, loc);
|
|
// Save unit preference
|
|
const unitRadio = modal.querySelector('input[name="weather-unit-radio"]:checked');
|
|
const newUnit = unitRadio ? unitRadio.value : 'imperial';
|
|
const oldUnit = getUnit();
|
|
safeSet(UNIT_KEY, newUnit);
|
|
// Clear geo cache if unit changed (need fresh weather data)
|
|
if (oldUnit !== newUnit) safeSet(GEO_CACHE_KEY, '');
|
|
modal.classList.remove('show');
|
|
updateWeather();
|
|
} else {
|
|
showNotification('Please enter a location (e.g., Hamburg, London, 90210)', 'warning');
|
|
}
|
|
});
|
|
|
|
wireModal(modal);
|
|
|
|
// Close modal on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
|
modal.classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// Initialize weather
|
|
updateWeather();
|
|
// Update weather every 10 minutes
|
|
setInterval(updateWeather, DC.POLL.WEATHER);
|
|
})();
|