thermograph/frontend/static/geoip.js

85 lines
3.5 KiB
JavaScript
Raw Normal View History

Approximate-location fallback from the client IP (feature-flagged off) A visitor who declines browser geolocation currently has no location at all — the copy sends them to the map picker. This adds an opt-in fallback that suggests a coarse city from the request's own IP, presented as a guess with a one-tap correction, so the dead end has a way out. backend/data/geoip.py does the lookup against a local MMDB file (DB-IP IP to City Lite or GeoLite2 City — same format, either works, attribution derived from the file's metadata). Off unless THERMOGRAPH_GEOIP is truthy AND the database exists AND maxminddb imports; every degraded case — private/reserved/ CGNAT/IPv6-ULA addresses, unparseable input, no record, a country-centroid record with no city, a record wider than the accuracy limit, a corrupt file — returns None, which the route answers as 204 and the client treats exactly as today. The IP is read in memory and dropped. GET /api/v2/geoip runs no RunAudit, records no metric dimension, and is excluded from the access log (its own "geoip" traffic category) so no stored, joinable (IP, location) pair is ever written. The response is no-store/Vary:* and takes no parameters. Client-side rather than server-rendered on purpose: the homepage's HTML and weak ETag must stay byte-identical for every visitor, or any cache added in front of it can serve one visitor's city to another. The suggestion is fetched only after a declined/unavailable prompt, and only when nothing is remembered. A guess is never persisted — no localStorage write, no URL hash — and the hero and results headings say "roughly near"/"near … approximate" rather than letting the reverse-geocoded cell name a neighbourhood the lookup never knew. The /privacy page's "never looks up your location from your IP address" paragraph now follows the same flag out of the same env file, so the published statement and the behaviour flip together. Database lifecycle is a host-side systemd timer (infra/deploy/geoip-refresh.*) that verifies a download opens and answers before swapping it in atomically, bind-mounted read-only; geoip.py re-opens on mtime change, so a refresh needs no restart. GEOIP-APPROX-LOCATION.md carries the database comparison, the SSR-vs-client argument, the privacy analysis, and the open decisions.
2026-07-23 23:22:37 +00:00
// Approximate-location fallback.
//
// The only reason this exists: a visitor who declines (or cannot use) browser
// geolocation currently hits a dead end — search or the map picker, or nothing.
// This asks the backend for a coarse guess derived from the request's own IP,
// and offers it as a *suggestion*.
//
// Three rules the UI must never break, because the accuracy gap is enormous
// (browser geolocation is metres; a city-level IP database is tens of
// kilometres, against a ~2 mile grid cell):
//
// 1. Say it is a guess, in the visitor's own words, right next to the result.
// Never present it as "your location".
// 2. Put the correction one obvious tap away, permanently visible — not
// hidden behind a dismissed toast.
// 3. Never persist it. An explicit choice is remembered (localStorage) and
// written into the URL hash; a guess is neither. A guess that became
// sticky would be indistinguishable from a choice on the next visit, and
// a guessed URL would propagate someone else's rough location when shared.
//
// The endpoint answers 204 for every "no" — feature off, no database, private
// or unparsable address, no match, match too coarse — so there is exactly one
// branch here and it is "behave exactly as the site does today".
import { uv } from "./account.js";
import { track } from "./digest.js";
import { esc } from "./shared.js";
/**
* Ask the backend for an approximate location.
* Resolves to the suggestion object, or null when there is nothing to suggest.
* Never rejects: a failed fetch is just "no suggestion".
*/
export async function fetchApprox() {
try {
const res = await fetch(uv("geoip"), { headers: { Accept: "application/json" } });
if (res.status !== 200) return null; // 204 is the normal "no"
const data = await res.json();
if (!data || !data.approximate) return null; // never trust an unlabelled payload
if (typeof data.lat !== "number" || typeof data.lon !== "number") return null;
return data;
} catch (e) {
return null;
}
}
function attributionHtml(a) {
// Both candidate databases are free *with attribution* and both ask for the
// credit on the page that displays a result — so it rides with the result
// rather than living only in a footer that this view doesn't have.
if (!a || !a.text) return "";
const text = esc(a.text);
return a.url
? ` <a class="geo-approx-credit" href="${esc(a.url)}" rel="noopener nofollow" target="_blank">${text}</a>`
: ` <span class="geo-approx-credit">${text}</span>`;
}
/**
* Render the suggestion banner into `host` and load the guessed place.
*
* @param host container element (hidden until there is something to show)
* @param data a suggestion from fetchApprox()
* @param onFix called when the visitor corrects the guess
*/
export function renderApprox(host, data, onFix) {
if (!host) return;
host.innerHTML =
`<p class="geo-approx-line">Showing weather near <b>${esc(data.label)}</b>.</p>` +
`<p class="geo-approx-note">That is a rough guess from your internet connection, ` +
`not from your device — it can be tens of kilometres out.` +
attributionHtml(data.attribution) + `</p>` +
`<button type="button" class="btn-ghost geo-approx-fix">Not right? Choose your spot</button>`;
host.hidden = false;
host.querySelector(".geo-approx-fix")?.addEventListener("click", () => {
track("home.geoip_fixed");
onFix?.();
});
track("home.geoip_shown");
}
export function clearApprox(host) {
if (!host) return;
host.hidden = true;
host.innerHTML = "";
}