Files
Karsten Hassel fb41d24ef5 Release 2.36.0 (#4127)
## Release Notes
Thanks to: @cgillinger, @khassel, @KristjanESPERANTO, @sonnyb9
> ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change
to previous release)

[Compare to previous Release
v2.35.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.35.0...v2.36.0)

This release falls outside the quarterly schedule. We opted for an early
release due to:
- Security fix for the internal cors proxy
- API change of the weather provider smi
- Several bug fixes

### Breaking Changes

The cors proxy is now disabled by default. If required, it must be
explicitly enabled in the `config.js` file. See the
[documentation](https://docs.magicmirror.builders/configuration/cors.html).

### ⚠️ Security

You can find several publicly accessible MagicMirror² instances.

This should never be done. Doing so makes your entire configuration,
including secrets and API keys, publicly visible. Furthermore, it allows
attackers to target the host; this is only prevented beginning with this
release.

Public MagicMirror² instances should always run behind a reverse proxy
with authentication.

### [core]
- Prepare Release 2.36.0 (#4126)
- Allow HTTPFetcher to pass through 304 responses (#4120)
- fix(http-fetcher): fall back to reloadInterval after retries exhausted
(#4113)
- config endpoint must handle functions in module configs (#4106)
- fix replaceSecretPlaceholder (#4104)
- restrict replaceSecretPlaceholder to cors with allowWhitelist (#4102)
- fix: prevent crash when config is undefined in socket handler (#4096)
- fix cors function for alpine linux (#4091)
- fix(cors): prevent SSRF via DNS rebinding (#4090)
- add option to disable or restrict cors endpoint (#4087)
- fix: prevent SSRF via /cors endpoint by blocking private/reserved IPs
(#4084)
- chore: add permissions section to enforce pull-request rules workflow
(#4079)
- update version for develop

### [dependencies]
- update dependencies (#4124)
- chore: update dependencies (#4088)
- refactor: enable ESLint rule "no-unused-vars" and handle related
issues (#4080)

### [modules/newsfeed]
- fix(newsfeed): prevent duplicate parse error callback when using
pipeline (#4083)

### [modules/updatenotification]
- fix(updatenotification): harden git command execution + simplify
checkUpdates (#4115)
- fix(tests): correct import path for git_helper module in
updatenotification tests (#4078)

### [modules/weather]
- fix(weather): use nearest openmeteo hourly data (#4123)
- fix(weather): avoid loading state after reconnect (#4121)
- weather: fix UV index display and add WeatherFlow precipitation
(#4108)
- fix(weather): restore OpenWeatherMap v2.5 support (#4101)
- fix(weather): use stable instanceId to prevent duplicate fetchers
(#4092)
- SMHI: migrate to SNOW1gv1 API (replace deprecated PMP3gv2) (#4082)

### [testing]
- ci(actions): set explicit token permissions (#4114)
- fix(http_fetcher): use undici.fetch when dispatcher is present (#4097)
- ci(codeql): also scan develop branch on push and PR (#4086)
- refactor: replace implicit global config with explicit global.config
(#4085)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: dathbe <github@beffa.us>
Co-authored-by: Marcel <m-idler@users.noreply.github.com>
Co-authored-by: Kevin G. <crazylegstoo@gmail.com>
Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com>
Co-authored-by: Jboucly <contact@jboucly.fr>
Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com>
Co-authored-by: Jordan Welch <JordanHWelch@gmail.com>
Co-authored-by: Blackspirits <blackspirits@gmail.com>
Co-authored-by: Samed Ozdemir <samed@xsor.io>
Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com>
Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com>
Co-authored-by: cgillinger <christian.gillinger@gmail.com>
Co-authored-by: Sonny B <43247590+sonnyb9@users.noreply.github.com>
Co-authored-by: sonnyb9 <sonnyb9@users.noreply.github.com>
2026-04-30 22:49:25 +02:00

407 lines
12 KiB
JavaScript

const Log = require("logger");
const weatherUtils = require("../provider-utils");
const HTTPFetcher = require("#http_fetcher");
/**
* Server-side weather provider for OpenWeatherMap
* see https://openweathermap.org/
*/
class OpenWeatherMapProvider {
constructor (config) {
this.config = {
apiVersion: "3.0",
apiBase: "https://api.openweathermap.org/data/",
weatherEndpoint: "/onecall",
locationID: false,
location: false,
lat: 0,
lon: 0,
apiKey: "",
type: "current",
updateInterval: 10 * 60 * 1000,
...config
};
this.fetcher = null;
this.onDataCallback = null;
this.onErrorCallback = null;
this.locationName = null;
}
initialize () {
// Validate callbacks exist
if (typeof this.onErrorCallback !== "function") {
throw new Error("setCallbacks() must be called before initialize()");
}
if (!this.config.apiKey) {
Log.error("[openweathermap] API key is required");
this.onErrorCallback({
message: "API key is required",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
return;
}
this.#initializeFetcher();
}
setCallbacks (onData, onError) {
this.onDataCallback = onData;
this.onErrorCallback = onError;
}
start () {
if (this.fetcher) {
this.fetcher.startPeriodicFetch();
}
}
stop () {
if (this.fetcher) {
this.fetcher.clearTimer();
}
}
#initializeFetcher () {
const url = this.#getUrl();
this.fetcher = new HTTPFetcher(url, {
reloadInterval: this.config.updateInterval,
headers: { "Cache-Control": "no-cache" },
logContext: "weatherprovider.openweathermap"
});
this.fetcher.on("response", async (response) => {
try {
const data = await response.json();
this.#handleResponse(data);
} catch (error) {
Log.error("[openweathermap] Failed to parse JSON:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: "Failed to parse API response",
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
});
this.fetcher.on("error", (errorInfo) => {
if (this.onErrorCallback) {
this.onErrorCallback(errorInfo);
}
});
}
#handleResponse (data) {
try {
let weatherData;
if (this.config.weatherEndpoint === "/onecall") {
// One Call API (v3.0)
if (data.timezone) {
this.locationName = data.timezone;
}
const onecallData = this.#generateWeatherObjectsFromOnecall(data);
switch (this.config.type) {
case "current":
weatherData = onecallData.current;
break;
case "forecast":
case "daily":
weatherData = onecallData.days;
break;
case "hourly":
weatherData = onecallData.hours;
break;
default:
Log.error(`[openweathermap] Unknown type: ${this.config.type}`);
throw new Error(`Unknown weather type: ${this.config.type}`);
}
} else if (this.config.weatherEndpoint === "/weather") {
// Current weather endpoint (API v2.5)
weatherData = this.#generateWeatherObjectFromCurrentWeather(data);
} else if (this.config.weatherEndpoint === "/forecast") {
// 3-hourly forecast endpoint (API v2.5)
weatherData = this.config.type === "hourly"
? this.#generateHourlyWeatherObjectsFromForecast(data)
: this.#generateDailyWeatherObjectsFromForecast(data);
} else {
throw new Error(`Unknown weather endpoint: ${this.config.weatherEndpoint}`);
}
if (weatherData && this.onDataCallback) {
this.onDataCallback(weatherData);
}
} catch (error) {
Log.error("[openweathermap] Error processing weather data:", error);
if (this.onErrorCallback) {
this.onErrorCallback({
message: error.message,
translationKey: "MODULE_ERROR_UNSPECIFIED"
});
}
}
}
#generateWeatherObjectFromCurrentWeather (data) {
const timezoneOffsetMinutes = (data.timezone ?? 0) / 60;
if (data.name && data.sys?.country) {
this.locationName = `${data.name}, ${data.sys.country}`;
} else if (data.name) {
this.locationName = data.name;
}
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(data.dt * 1000), timezoneOffsetMinutes);
weather.temperature = data.main.temp;
weather.feelsLikeTemp = data.main.feels_like;
weather.humidity = data.main.humidity;
weather.windSpeed = data.wind.speed;
weather.windFromDirection = data.wind.deg;
weather.weatherType = weatherUtils.convertWeatherType(data.weather[0].icon);
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunrise * 1000), timezoneOffsetMinutes);
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(data.sys.sunset * 1000), timezoneOffsetMinutes);
return weather;
}
#extractThreeHourPrecipitation (forecast) {
const rain = Number.parseFloat(forecast.rain?.["3h"] ?? "") || 0;
const snow = Number.parseFloat(forecast.snow?.["3h"] ?? "") || 0;
const precipitationAmount = rain + snow;
return {
rain,
snow,
precipitationAmount,
hasPrecipitation: precipitationAmount > 0
};
}
#generateHourlyWeatherObjectsFromForecast (data) {
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
if (data.city?.name && data.city?.country) {
this.locationName = `${data.city.name}, ${data.city.country}`;
}
return data.list.map((forecast) => {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes);
weather.temperature = forecast.main.temp;
weather.feelsLikeTemp = forecast.main.feels_like;
weather.humidity = forecast.main.humidity;
weather.windSpeed = forecast.wind.speed;
weather.windFromDirection = forecast.wind.deg;
weather.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
weather.precipitationProbability = forecast.pop !== undefined ? forecast.pop * 100 : undefined;
const precipitation = this.#extractThreeHourPrecipitation(forecast);
if (precipitation.hasPrecipitation) {
weather.rain = precipitation.rain;
weather.snow = precipitation.snow;
weather.precipitationAmount = precipitation.precipitationAmount;
}
return weather;
});
}
#generateDailyWeatherObjectsFromForecast (data) {
const timezoneOffsetSeconds = data.city?.timezone ?? 0;
const timezoneOffsetMinutes = timezoneOffsetSeconds / 60;
if (data.city?.name && data.city?.country) {
this.locationName = `${data.city.name}, ${data.city.country}`;
}
const dayMap = new Map();
for (const forecast of data.list) {
// Shift dt by timezone offset so UTC fields represent local time
const localDate = new Date((forecast.dt + timezoneOffsetSeconds) * 1000);
const dateKey = `${localDate.getUTCFullYear()}-${String(localDate.getUTCMonth() + 1).padStart(2, "0")}-${String(localDate.getUTCDate()).padStart(2, "0")}`;
if (!dayMap.has(dateKey)) {
dayMap.set(dateKey, {
date: weatherUtils.applyTimezoneOffset(new Date(forecast.dt * 1000), timezoneOffsetMinutes),
minTemps: [],
maxTemps: [],
rain: 0,
snow: 0,
weatherType: weatherUtils.convertWeatherType(forecast.weather[0].icon)
});
}
const day = dayMap.get(dateKey);
day.minTemps.push(forecast.main.temp_min);
day.maxTemps.push(forecast.main.temp_max);
const hour = localDate.getUTCHours();
if (hour >= 8 && hour <= 17) {
day.weatherType = weatherUtils.convertWeatherType(forecast.weather[0].icon);
}
const precipitation = this.#extractThreeHourPrecipitation(forecast);
day.rain += precipitation.rain;
day.snow += precipitation.snow;
}
return Array.from(dayMap.values()).map((day) => ({
date: day.date,
minTemperature: Math.min(...day.minTemps),
maxTemperature: Math.max(...day.maxTemps),
weatherType: day.weatherType,
rain: day.rain,
snow: day.snow,
precipitationAmount: day.rain + day.snow
}));
}
#generateWeatherObjectsFromOnecall (data) {
let precip;
// Get current weather
const current = {};
if (data.hasOwnProperty("current")) {
const timezoneOffset = data.timezone_offset / 60;
current.date = weatherUtils.applyTimezoneOffset(new Date(data.current.dt * 1000), timezoneOffset);
current.windSpeed = data.current.wind_speed;
current.windFromDirection = data.current.wind_deg;
current.sunrise = weatherUtils.applyTimezoneOffset(new Date(data.current.sunrise * 1000), timezoneOffset);
current.sunset = weatherUtils.applyTimezoneOffset(new Date(data.current.sunset * 1000), timezoneOffset);
current.temperature = data.current.temp;
current.weatherType = weatherUtils.convertWeatherType(data.current.weather[0].icon);
current.humidity = data.current.humidity;
current.uvIndex = data.current.uvi;
precip = false;
if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) {
current.rain = data.current.rain["1h"];
precip = true;
}
if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) {
current.snow = data.current.snow["1h"];
precip = true;
}
if (precip) {
current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);
}
current.feelsLikeTemp = data.current.feels_like;
}
// Get hourly weather
const hours = [];
if (data.hasOwnProperty("hourly")) {
const timezoneOffset = data.timezone_offset / 60;
for (const hour of data.hourly) {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset);
weather.temperature = hour.temp;
weather.feelsLikeTemp = hour.feels_like;
weather.humidity = hour.humidity;
weather.windSpeed = hour.wind_speed;
weather.windFromDirection = hour.wind_deg;
weather.weatherType = weatherUtils.convertWeatherType(hour.weather[0].icon);
weather.precipitationProbability = hour.pop !== undefined ? hour.pop * 100 : undefined;
weather.uvIndex = hour.uvi;
precip = false;
if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) {
weather.rain = hour.rain["1h"];
precip = true;
}
if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) {
weather.snow = hour.snow["1h"];
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
hours.push(weather);
}
}
// Get daily weather
const days = [];
if (data.hasOwnProperty("daily")) {
const timezoneOffset = data.timezone_offset / 60;
for (const day of data.daily) {
const weather = {};
weather.date = weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset);
weather.sunrise = weatherUtils.applyTimezoneOffset(new Date(day.sunrise * 1000), timezoneOffset);
weather.sunset = weatherUtils.applyTimezoneOffset(new Date(day.sunset * 1000), timezoneOffset);
weather.minTemperature = day.temp.min;
weather.maxTemperature = day.temp.max;
weather.humidity = day.humidity;
weather.windSpeed = day.wind_speed;
weather.windFromDirection = day.wind_deg;
weather.weatherType = weatherUtils.convertWeatherType(day.weather[0].icon);
weather.precipitationProbability = day.pop !== undefined ? day.pop * 100 : undefined;
weather.uvIndex = day.uvi;
precip = false;
if (!isNaN(day.rain)) {
weather.rain = day.rain;
precip = true;
}
if (!isNaN(day.snow)) {
weather.snow = day.snow;
precip = true;
}
if (precip) {
weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);
}
days.push(weather);
}
}
return { current, hours, days };
}
#getUrl () {
return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.#getParams();
}
#getParams () {
let params = "?";
if (this.config.weatherEndpoint === "/onecall") {
params += `lat=${this.config.lat}`;
params += `&lon=${this.config.lon}`;
if (this.config.type === "current") {
params += "&exclude=minutely,hourly,daily";
} else if (this.config.type === "hourly") {
params += "&exclude=current,minutely,daily";
} else if (this.config.type === "daily" || this.config.type === "forecast") {
params += "&exclude=current,minutely,hourly";
} else {
params += "&exclude=minutely";
}
} else if (this.config.lat && this.config.lon) {
params += `lat=${this.config.lat}&lon=${this.config.lon}`;
} else if (this.config.locationID) {
params += `id=${this.config.locationID}`;
} else if (this.config.location) {
params += `q=${this.config.location}`;
}
params += "&units=metric";
params += `&lang=${this.config.lang || "en"}`;
params += `&APPID=${this.config.apiKey}`;
return params;
}
}
module.exports = OpenWeatherMapProvider;