Design: approximate location from client IP (feature-flagged off, decisions needed) #34

Open
admin_emi wants to merge 1 commit from feat/geoip-approx-location into dev
Owner

When a visitor declines/lacks browser geolocation, infer a coarse location from their IP so the site can still show local weather, instead of dead-ending at search/map. Feature-flagged OFF (THERMOGRAPH_GEOIP), not deployed — this PR is for review of the approach, not immediate merge-and-ship.

Key design choices

  • Self-hosted DB-IP IP-to-City Lite (CC BY 4.0, no account/licence key needed) over MaxMind GeoLite2 (which requires a licence key AND a 30-day-delete-on-new-release EULA clause — a compliance clock on a cron job). Both are MMDB; swapping later is config, not code.
  • Client-side, not SSR: SSR would make the homepage's ETag a function of the visitor's city with no correct Vary header (varies on XFF, which caches don't key on) — would poison shared caches. Also can't see a visitor's already-saved localStorage place.
  • The IP is read in memory and discarded — never persisted. The lookup route is excluded from the access log entirely (new geoip category in classify_inbound), so no (IP, location) pair is ever written to disk or Loki.
  • No consent banner needed (contrasted with the separate UI-analytics work, which does need one): transient processing to deliver the requested service, nothing written to the device, so ePrivacy Art. 5(3) isn't engaged.
  • UI must present the guess as a suggestion, never fact (~50km city-level accuracy vs the ~2mi grid) — copy + correction affordance designed accordingly.
  • Privacy page currently states "Thermograph never looks up your location from your IP address." This PR makes that conditionally false when the flag is on — the paragraph is now flag-gated so statement and behavior flip together; both branches tested.

Tests: cd backend && make test → 449 passed, 8 skipped (baseline 386/7). cd frontend && make test-unit → 41 passed.

Decisions needed before this ships (full detail in GEOIP-APPROX-LOCATION.md):

  1. Confirm DB-IP Lite vs GeoLite2 vs IP2Location.
  2. Attribution placement (currently: in the banner beside the result, per DB-IP's "on pages displaying results" requirement).
  3. Refresh cadence (currently weekly, self-healing no-op on failure).
  4. Whether to allow the one auto-on-load path (insecure contexts only, never fires on prod).
  5. Pick the geoip_fixed / geoip_shown ratio that would trigger turning this off, before rollout — not after.

Flagged as bad ideas, argued against in the doc: persisting the guess; a location-varying canonical homepage; extending the lookup to units/language/default-city (changes the legal basis from "necessary for the requested service" to personalisation); writing the derived city into any metric/audit field; gating this behind the analytics consent flag (different legal basis — would strand exactly the visitors who decline analytics).

When a visitor declines/lacks browser geolocation, infer a coarse location from their IP so the site can still show local weather, instead of dead-ending at search/map. **Feature-flagged OFF (`THERMOGRAPH_GEOIP`), not deployed** — this PR is for review of the approach, not immediate merge-and-ship. **Key design choices** - Self-hosted **DB-IP IP-to-City Lite** (CC BY 4.0, no account/licence key needed) over MaxMind GeoLite2 (which requires a licence key AND a 30-day-delete-on-new-release EULA clause — a compliance clock on a cron job). Both are MMDB; swapping later is config, not code. - **Client-side, not SSR**: SSR would make the homepage's ETag a function of the visitor's city with no correct `Vary` header (varies on XFF, which caches don't key on) — would poison shared caches. Also can't see a visitor's already-saved localStorage place. - **The IP is read in memory and discarded — never persisted.** The lookup route is excluded from the access log entirely (new `geoip` category in `classify_inbound`), so no (IP, location) pair is ever written to disk or Loki. - **No consent banner needed** (contrasted with the separate UI-analytics work, which does need one): transient processing to deliver the requested service, nothing written to the device, so ePrivacy Art. 5(3) isn't engaged. - UI must present the guess as a suggestion, never fact (~50km city-level accuracy vs the ~2mi grid) — copy + correction affordance designed accordingly. - **Privacy page currently states "Thermograph never looks up your location from your IP address."** This PR makes that conditionally false when the flag is on — the paragraph is now flag-gated so statement and behavior flip together; both branches tested. **Tests**: `cd backend && make test` → 449 passed, 8 skipped (baseline 386/7). `cd frontend && make test-unit` → 41 passed. **Decisions needed before this ships** (full detail in `GEOIP-APPROX-LOCATION.md`): 1. Confirm DB-IP Lite vs GeoLite2 vs IP2Location. 2. Attribution placement (currently: in the banner beside the result, per DB-IP's "on pages displaying results" requirement). 3. Refresh cadence (currently weekly, self-healing no-op on failure). 4. Whether to allow the one auto-on-load path (insecure contexts only, never fires on prod). 5. **Pick the `geoip_fixed / geoip_shown` ratio that would trigger turning this off, before rollout** — not after. **Flagged as bad ideas, argued against in the doc**: persisting the guess; a location-varying canonical homepage; extending the lookup to units/language/default-city (changes the legal basis from "necessary for the requested service" to personalisation); writing the derived city into any metric/audit field; gating this behind the analytics consent flag (different legal basis — would strand exactly the visitors who decline analytics).
admin_emi added 1 commit 2026-07-24 04:07:30 +00:00
Approximate-location fallback from the client IP (feature-flagged off)
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m8s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 2s
083d3bd0f8
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.
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m8s
PR build (required check) / build-backend (pull_request) Successful in 1m36s
PR build (required check) / gate (pull_request) Successful in 2s
Required
Details
This pull request has changes conflicting with the target branch.
  • backend/core/metrics.py
  • backend/requirements.txt
  • backend/web/app.py
  • infra/docker-compose.yml
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/geoip-approx-location:feat/geoip-approx-location
git checkout feat/geoip-approx-location
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Jinemi/thermograph#34
No description provided.