diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index f2ae41d4b..d39d2f25b 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -30,7 +30,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t | `PUID` | User ID to run the container as | Any valid user ID | `0` | | `PGID` | Group ID to run the container as | Any valid group ID | `0` | | `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | -| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | +| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, location search, and RSS feeds | `true` or `false` | `false` | | `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | | `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` | diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 0ff4081d7..0542887cc 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -1,26 +1,22 @@ --- -title: 'Weather' -description: 'Displays the current weather information of a set location.' +title: "Weather" +description: "Displays the current weather information of a set location." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { weatherWidget } from '.'; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { weatherWidget } from "."; - - + The Weather Widget uses your location to display the current, highest and lowest temperature. ### Screenshots -import forecastWeather from './img/forecast.png'; -import currentWeather from './img/current.png'; +import forecastWeather from "./img/forecast.png"; +import currentWeather from "./img/current.png";
weather forecast-5-days @@ -32,9 +28,15 @@ import currentWeather from './img/current.png'; ### Configuration + ### Data source We use the Open Meteo API to fetch the weather and locations. -If you need to whitelist the api, please use the following URL: `https://open-meteo.com` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `https://open-meteo.com` + +:::note +When the `NO_EXTERNAL_CONNECTION` environment variable is enabled, the widget (and the city +search inside its configuration) cannot load any data from Open Meteo. +::: diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts index 99bb9c3af..556b64ab1 100644 --- a/packages/api/src/router/location.ts +++ b/packages/api/src/router/location.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; +import { env } from "@homarr/common/env"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; @@ -37,6 +38,8 @@ export const locationRouter = createTRPCRouter({ .input(locationSearchCityInput) .output(locationSearchCityOutput) .query(async ({ input }) => { + if (env.NO_EXTERNAL_CONNECTION) return { results: [] }; + const res = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, diff --git a/packages/api/src/router/test/location.spec.ts b/packages/api/src/router/test/location.spec.ts new file mode 100644 index 000000000..0641dffb1 --- /dev/null +++ b/packages/api/src/router/test/location.spec.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +const mocks = vi.hoisted(() => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), + env: { NO_EXTERNAL_CONNECTION: false }, +})); + +vi.mock("@homarr/common/env", () => ({ + env: mocks.env, +})); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync, +})); + +vi.mock("@homarr/core/infrastructure/http/timeout", () => ({ + withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise) => { + return await callback(new AbortController().signal); + }, +})); + +import { locationRouter } from "../location"; + +const createJsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + +const createCaller = () => locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null }); + +afterEach(() => { + mocks.env.NO_EXTERNAL_CONNECTION = false; + mocks.fetchWithTrustedCertificatesAsync.mockReset(); +}); + +describe("locationRouter.searchCity", () => { + test("should return empty results when external connections are disabled", async () => { + // Arrange + mocks.env.NO_EXTERNAL_CONNECTION = true; + const caller = createCaller(); + + // Act + const result = await caller.searchCity({ query: "Paris" }); + + // Assert + expect(result.results).toEqual([]); + expect(mocks.fetchWithTrustedCertificatesAsync).not.toHaveBeenCalled(); + }); + + test("should fetch the geocoding API when external connections are enabled", async () => { + // Arrange + mocks.env.NO_EXTERNAL_CONNECTION = false; + mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue( + createJsonResponse({ + results: [ + { + id: 1, + name: "Paris", + country: "France", + country_code: "FR", + latitude: 48.85341, + longitude: 2.3488, + population: 2138551, + }, + ], + }), + ); + const caller = createCaller(); + + // Act + const result = await caller.searchCity({ query: "Paris" }); + + // Assert + expect(result.results[0]?.name).toBe("Paris"); + expect(mocks.fetchWithTrustedCertificatesAsync).toHaveBeenCalledWith( + expect.stringContaining("geocoding-api.open-meteo.com"), + expect.anything(), + ); + }); +}); diff --git a/packages/api/src/router/test/widgets/weather.spec.ts b/packages/api/src/router/test/widgets/weather.spec.ts new file mode 100644 index 000000000..3b605aee7 --- /dev/null +++ b/packages/api/src/router/test/widgets/weather.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +const mocks = vi.hoisted(() => ({ + getDataAsync: vi.fn(), + handler: vi.fn(), + env: { NO_EXTERNAL_CONNECTION: false }, +})); + +vi.mock("@homarr/common/env", () => ({ + env: mocks.env, +})); + +vi.mock("@homarr/request-handler/weather", () => ({ + weatherRequestHandler: { + handler: mocks.handler, + }, +})); + +import { weatherRouter } from "../../widgets/weather"; + +const weatherData = { + current: { weathercode: 0, temperature: 20, windspeed: 10 }, + daily: [], +}; + +const createCaller = () => weatherRouter.createCaller({ db: createDb(), deviceType: undefined, session: null }); + +afterEach(() => { + mocks.env.NO_EXTERNAL_CONNECTION = false; + mocks.handler.mockReset(); + mocks.getDataAsync.mockReset(); +}); + +describe("weatherRouter.atLocation", () => { + test("should return null when external connections are disabled", async () => { + // Arrange + mocks.env.NO_EXTERNAL_CONNECTION = true; + const caller = createCaller(); + + // Act + const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 }); + + // Assert + expect(result).toBeNull(); + expect(mocks.handler).not.toHaveBeenCalled(); + }); + + test("should return the weather data when external connections are enabled", async () => { + // Arrange + mocks.env.NO_EXTERNAL_CONNECTION = false; + mocks.handler.mockReturnValue({ getDataAsync: mocks.getDataAsync }); + mocks.getDataAsync.mockResolvedValue({ data: weatherData, timestamp: new Date() }); + const caller = createCaller(); + + // Act + const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 }); + + // Assert + expect(result).toEqual(weatherData); + expect(mocks.handler).toHaveBeenCalledWith({ latitude: 48.85341, longitude: 2.3488 }); + }); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 13356dedf..9918291d4 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2309,6 +2309,11 @@ } }, "currentWindSpeed": "{currentWindSpeed} {unit}", + "error": { + "disabled": "Weather data is unavailable because outbound connections are disabled", + "failed": "Failed to load weather data", + "retry": "Retry" + }, "dailyForecast": { "sunrise": "Sunrise", "sunset": "Sunset", diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 6b11453b2..c9d575314 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,7 +1,14 @@ "use client"; -import { Box, Group, HoverCard, Stack, Text } from "@mantine/core"; -import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; +import { Box, Button, Group, HoverCard, Stack, Text } from "@mantine/core"; +import { + IconArrowDownRight, + IconArrowUpRight, + IconCloudOff, + IconDroplets, + IconMapPin, + IconWind, +} from "@tabler/icons-react"; import combineClasses from "clsx"; import dayjs from "dayjs"; @@ -16,13 +23,38 @@ import { AnimatedWeatherIcon } from "./animated-icon"; import { WeatherDescription } from "./icon"; export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) { + const tError = useScopedI18n("widget.weather.error"); const input = { latitude: options.location.latitude, longitude: options.location.longitude, }; - const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input); + const { data: weather, isPending, isError, refetch } = clientApi.widget.weather.atLocation.useQuery(input); - if (!weather) return ; + if (isPending) return ; + + if (isError) { + return ( + + + + {tError("failed")} + + + + ); + } + + if (!weather) { + return ( + + + {tError("disabled")} + + + ); + } return (