diff --git a/deploy/vps/nginx/laikapstak.li.conf.example b/deploy/vps/nginx/laikapstak.li.conf.example index 37822ea..d19224d 100644 --- a/deploy/vps/nginx/laikapstak.li.conf.example +++ b/deploy/vps/nginx/laikapstak.li.conf.example @@ -37,6 +37,12 @@ server { include /etc/nginx/snippets/weathertool-authelia-location.conf; + location /api/ { + include /etc/nginx/snippets/weathertool-proxy.conf; + include /etc/nginx/snippets/weathertool-authelia-api-authrequest.conf; + proxy_pass http://127.0.0.1:8002; + } + location / { include /etc/nginx/snippets/weathertool-proxy.conf; include /etc/nginx/snippets/weathertool-authelia-authrequest.conf; diff --git a/deploy/vps/nginx/snippets/weathertool-authelia-api-authrequest.conf b/deploy/vps/nginx/snippets/weathertool-authelia-api-authrequest.conf new file mode 100644 index 0000000..8623d52 --- /dev/null +++ b/deploy/vps/nginx/snippets/weathertool-authelia-api-authrequest.conf @@ -0,0 +1,15 @@ +auth_request /internal/authelia/authz; + +auth_request_set $user $upstream_http_remote_user; +auth_request_set $groups $upstream_http_remote_groups; +auth_request_set $name $upstream_http_remote_name; +auth_request_set $email $upstream_http_remote_email; + +proxy_set_header Remote-User $user; +proxy_set_header Remote-Groups $groups; +proxy_set_header Remote-Name $name; +proxy_set_header Remote-Email $email; + +# API clients must receive the original 401 response. Redirecting an XHR/fetch +# request to auth.laikapstak.li turns it into a cross-origin request which the +# browser blocks as CORS and leaves the application waiting for data. diff --git a/docs/DEVELOPMENT_AND_STAGING.md b/docs/DEVELOPMENT_AND_STAGING.md index cf653e8..84d4a56 100644 --- a/docs/DEVELOPMENT_AND_STAGING.md +++ b/docs/DEVELOPMENT_AND_STAGING.md @@ -115,6 +115,24 @@ At commit `de6f279`, all five checks pass and both audit scopes report zero know Known limitation: direct browser refreshes on newer client-side routes such as `/faktiska` can return 404 because the backend static-route list does not yet provide a general SPA fallback. Navigate from the home page until that backend behavior is fixed. +## Authenticated API requests + +The public Nginx configuration deliberately handles `/api/` separately from +browser page requests. An expired Authelia session must return the original +HTTP 401 to an API request; it must not redirect `fetch`/XHR to +`auth.laikapstak.li`. A cross-origin authentication redirect is blocked by the +browser as CORS and previously left City Analysis appearing to load forever. + +The frontend also applies a 30-second query timeout and presents explicit +session, network, HTTP, unexpected-response, and empty-result messages. When +changing the authentication or proxy configuration, verify both an authorized +query and an expired-session query rather than checking page navigation alone. + +The UAT synthetic dataset is time-bounded. If its latest observation predates +the default City Analysis range, refresh the small test seed; an empty result is +not an ingestion or authentication failure. Synthetic data exists only for +visual workflow testing and must be removed before real provider ingestion. + ## Rollback Prefer a normal Git revert rather than manually copying old files: diff --git a/docs/VPS_STAGING_PLAN.md b/docs/VPS_STAGING_PLAN.md index 88f727c..a5436b3 100644 --- a/docs/VPS_STAGING_PLAN.md +++ b/docs/VPS_STAGING_PLAN.md @@ -293,6 +293,8 @@ development convenience, not the proposed VPS release model. - Valid login, session persistence, logout, throttling, and temporary ban recovery work. - `/`, City Analysis, Faktiskā, and Ūdens temperatūra work with synthetic data. +- An expired session on `/api/` returns HTTP 401 without a cross-origin redirect; + the UI leaves its loading state and explains how to sign in again. - Required PNG dimensions and filenames remain correct after deployment. - Browser developer tools and container/Nginx/Authelia logs show no unexpected errors or secret values. diff --git a/web/src/css/Result.css b/web/src/css/Result.css index 36c06c0..b6179a8 100644 --- a/web/src/css/Result.css +++ b/web/src/css/Result.css @@ -10,6 +10,10 @@ margin: 0; } +.queryError { + margin-top: 16px; +} + .grid-view { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); diff --git a/web/src/pages/cities/result/QueryView.tsx b/web/src/pages/cities/result/QueryView.tsx index 6d38b28..7d46096 100644 --- a/web/src/pages/cities/result/QueryView.tsx +++ b/web/src/pages/cities/result/QueryView.tsx @@ -1,5 +1,5 @@ import moment from 'moment' -import { Accessor, Component, createResource, createSignal } from "solid-js"; +import { Accessor, Component, createResource, createSignal, Show } from "solid-js"; import "../../../css/Result.css" import { FETCH_DELAY_MS, apiHost } from "../../../consts"; @@ -20,12 +20,47 @@ export const QueryView: Component<{ const queryEnd = () => moment(props.getEnd()).format("YYYYMMDD_HHmm"); const [getTimestamp, setTimestamp] = createSignal(0); - const fetchQuery = async (timestamp: number) => { + const fetchQuery = async (_timestamp: number) => { if (cities() === "") return undefined; await new Promise(resolve => setTimeout(resolve, FETCH_DELAY_MS)) - const response = await fetch(`${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`); - const json = await response.json(); - return json; + + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch( + `${apiHost}/api/query/city/${cities()}/${queryStart()}-${queryEnd()}/${props.getGranularity()}/${props.getField()}/${props.getKey()}`, + { + credentials: "same-origin", + headers: { Accept: "application/json" }, + signal: controller.signal, + }, + ); + + if (response.status === 401 || response.status === 403 || response.redirected) { + throw new Error("Your login session has expired. Refresh the page and sign in again."); + } + if (!response.ok) { + throw new Error(`The query failed (HTTP ${response.status}). Please try again.`); + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + throw new Error("The server returned an unexpected response. Refresh the page and sign in again."); + } + + return await response.json(); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error("The query took too long. Please try again."); + } + if (error instanceof TypeError) { + throw new Error("Could not reach the data service. Refresh the page and try again."); + } + throw error; + } finally { + window.clearTimeout(timeout); + } } const [queryResource] = createResource(getTimestamp, fetchQuery); @@ -40,12 +75,17 @@ export const QueryView: Component<{ onClick={() => setTimestamp(Date.now())} /> { queryResource.loading && } - { queryResource.error && ( -
Error while querying: ${queryResource.error}
- )} + + + { cities() === "" &&
Select at least one cityYour results and map options will appear here after you choose cities and run the query.
} { queryResource() && isQueryResult( queryResource() ) && - + (Object.keys(queryResource()!.result).length > 0 + ? + :
No observations foundChoose a time range covered by the available data and run the query again.
) } );