mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 21:08:13 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcc7f45727 |
@@ -7,28 +7,6 @@ on:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
# CI flake mitigation:
|
||||
# ci.yml is triggered TWICE per PR on the same commit — once directly via
|
||||
# the `pull_request` trigger above ("Frontend Tests & Build" check) and once
|
||||
# via `workflow_call` from docker-publish.yml ("CI Gate / Frontend Tests &
|
||||
# Build" check). Both jobs land on the same Actions runner pool at the same
|
||||
# time and fight for CPU/RAM. Under contention, React's reconciliation in
|
||||
# `messagesViewFirstContact.test.tsx > removes an approved contact …`
|
||||
# overruns its 5s waitFor timeout — that's the single failure mode we've
|
||||
# seen flake on PRs #226, #237, #261, #262, #265, #294, #303, and the
|
||||
# fd7d6fa push. Backend tests and every other frontend test pass under
|
||||
# the same conditions, which is what made this look random.
|
||||
#
|
||||
# Pinning a concurrency group on the SHA (PR head, or the pushed commit
|
||||
# for main) serializes the two invocations so neither starves the other.
|
||||
# We use cancel-in-progress: false so the second one queues instead of
|
||||
# cancelling — cancelling could leave the PR check stuck "Expected" if
|
||||
# only one of the two ever finishes. Total CI time grows by ~2 min in
|
||||
# exchange for deterministic outcomes.
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Frontend Tests & Build
|
||||
|
||||
-29
@@ -261,32 +261,3 @@ backend/data/wormhole_stdout.log
|
||||
|
||||
# Compressed snapshot archives (can be 100 MB+)
|
||||
*.json.gz
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# AI assistant / coding-agent scratch
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Per-tool config + scratch directories. These are private to whichever
|
||||
# coding agent the operator happens to be using and have no business in
|
||||
# the repo. If a tool's instructions need to be canonical for the project,
|
||||
# we'll put them in docs/ explicitly — not let the agent dump them at the
|
||||
# repo root.
|
||||
|
||||
# OpenAI Codex CLI
|
||||
.codex/
|
||||
.codex-app-schema/
|
||||
.codex-app-ts/
|
||||
|
||||
# Per-agent instruction files dropped at repo root by various tools.
|
||||
# These are operator-side preferences, not part of the project contract.
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
CLAUDE.md
|
||||
.github/copilot-instructions.md
|
||||
|
||||
# Stale AI-generated test file that referenced fields that don't exist in
|
||||
# the current `_parse_carrier_positions_from_news` implementation. Kept
|
||||
# ignored so it doesn't accidentally get committed if it shows up again
|
||||
# from a tool that's working off an out-of-date understanding of the
|
||||
# module. If a real test for that function is needed, write it under a
|
||||
# meaningful name in tests/test_carrier_tracker_quality.py.
|
||||
backend/tests/test_carrier_tracker_region_centers.py
|
||||
|
||||
+1
-105
@@ -1,108 +1,4 @@
|
||||
"""Rate-limit key function for slowapi.
|
||||
|
||||
Issue #287 (tg12): the previous implementation used
|
||||
``slowapi.util.get_remote_address`` which only ever returns
|
||||
``request.client.host``. Behind the bundled Next.js proxy (or any other
|
||||
reverse proxy), every connected operator's ``client.host`` is the
|
||||
frontend container's bridge IP. ``@limiter.limit("120/minute")`` then
|
||||
collapses into one shared bucket for everybody on the same backend —
|
||||
one heavy tab can starve every other operator on the node.
|
||||
|
||||
This module replaces that key function with one that:
|
||||
|
||||
* Reads ``X-Forwarded-For`` ONLY when the immediate peer is a trusted
|
||||
frontend container (same allowlist used by the Docker bridge
|
||||
local-operator trust path — see ``backend/auth.py`` ``#250``).
|
||||
* Picks the FIRST entry in the XFF chain. That's the client end of
|
||||
the proxy chain, which is the operator we want to bucket on.
|
||||
* Falls back to ``request.client.host`` for any peer that isn't on
|
||||
the trusted-frontend allowlist. Direct hits, unrelated containers,
|
||||
and unknown hosts are bucketed exactly like before — there is no
|
||||
way for an untrusted caller to spoof XFF and steal another
|
||||
operator's rate-limit bucket.
|
||||
|
||||
Single-operator nodes are unaffected: the frontend resolves to one IP,
|
||||
that IP is on the trust list, the XFF header is read, and you get one
|
||||
bucket per operator (i.e. you).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
|
||||
def _client_host(request: Any) -> str:
|
||||
"""Return the immediate peer's IP, normalised to a lowercase string."""
|
||||
client = getattr(request, "client", None)
|
||||
if client is None:
|
||||
return ""
|
||||
host = getattr(client, "host", "") or ""
|
||||
return host.lower()
|
||||
|
||||
|
||||
def _first_forwarded_for(value: str) -> str:
|
||||
"""Return the first non-empty entry from an ``X-Forwarded-For`` header.
|
||||
|
||||
RFC 7239 / de-facto XFF format is ``client, proxy1, proxy2, …``. The
|
||||
client end is what we want to bucket on. Empty parts (which appear
|
||||
in some malformed headers) are skipped so we don't end up keying on
|
||||
an empty string.
|
||||
"""
|
||||
for raw in value.split(","):
|
||||
candidate = raw.strip()
|
||||
if candidate:
|
||||
return candidate.lower()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_trusted_frontend_peer(host: str) -> bool:
|
||||
"""True iff ``host`` is one of the resolved trusted-frontend IPs.
|
||||
|
||||
Imported lazily so this module stays usable in unit tests that
|
||||
don't want to pull the whole auth module into scope.
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
from auth import _resolve_trusted_bridge_ips
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
try:
|
||||
trusted_ips = _resolve_trusted_bridge_ips()
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return False
|
||||
return host in trusted_ips
|
||||
|
||||
|
||||
def shadowbroker_rate_limit_key(request: Any) -> str:
|
||||
"""slowapi key_func that is proxy-aware on trusted frontend peers only.
|
||||
|
||||
Behaviour matrix:
|
||||
|
||||
* Direct loopback / unknown peer → ``request.client.host``
|
||||
(identical to slowapi's default ``get_remote_address``).
|
||||
* Peer is a trusted frontend container AND ``X-Forwarded-For`` is
|
||||
present → first XFF entry (the actual operator).
|
||||
* Peer is a trusted frontend container but no XFF → fall back to
|
||||
``request.client.host`` (the bridge IP). One shared bucket for
|
||||
everyone in that case, same as before — but you only get there
|
||||
if the trusted frontend forgot to forward XFF, which it won't.
|
||||
"""
|
||||
peer = _client_host(request)
|
||||
if _is_trusted_frontend_peer(peer):
|
||||
headers = getattr(request, "headers", None)
|
||||
if headers is not None:
|
||||
xff = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For")
|
||||
if xff:
|
||||
first = _first_forwarded_for(xff)
|
||||
if first:
|
||||
return first
|
||||
# Untrusted peer (or trusted peer without XFF): match the original
|
||||
# get_remote_address behaviour byte-for-byte.
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
limiter = Limiter(key_func=shadowbroker_rate_limit_key)
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
+10
-105
@@ -98,88 +98,6 @@ def _current_etag(prefix: str = "") -> str:
|
||||
return f"{prefix}v{get_data_version()}-l{get_active_layers_version()}"
|
||||
|
||||
|
||||
# ── Issue #288: viewport-aware payloads ─────────────────────────────────────
|
||||
# Heavy, density-driven, time-sensitive layers that benefit from bbox
|
||||
# filtering. Light reference layers (datacenters, military_bases,
|
||||
# power_plants, satellites, weather, news, etc.) are intentionally NOT
|
||||
# in these sets — they ship world-scale even when bounds are supplied so
|
||||
# panning never reveals an "empty world" of static infrastructure.
|
||||
#
|
||||
# When the caller does NOT pass s/w/n/e, none of this runs and the response
|
||||
# is byte-for-byte identical to the pre-#288 behavior.
|
||||
_FAST_BBOX_HEAVY_KEYS: tuple[str, ...] = (
|
||||
"commercial_flights",
|
||||
"military_flights",
|
||||
"private_flights",
|
||||
"private_jets",
|
||||
"tracked_flights",
|
||||
"ships",
|
||||
"cctv",
|
||||
"uavs",
|
||||
"liveuamap",
|
||||
"gps_jamming",
|
||||
"sigint",
|
||||
"trains",
|
||||
)
|
||||
_SLOW_BBOX_HEAVY_KEYS: tuple[str, ...] = (
|
||||
"gdelt",
|
||||
"firms_fires",
|
||||
"kiwisdr",
|
||||
"scanners",
|
||||
"psk_reporter",
|
||||
)
|
||||
|
||||
|
||||
def _has_full_bbox(s, w, n, e) -> bool:
|
||||
return None not in (s, w, n, e)
|
||||
|
||||
|
||||
def _bbox_etag_suffix(s, w, n, e) -> str:
|
||||
"""Quantize bbox to 1° before mixing into the ETag.
|
||||
|
||||
The 20% padding inside _bbox_filter already absorbs sub-degree pans;
|
||||
quantizing here means small mouse drags don't blow the ETag cache
|
||||
on the client. Full-world bounds collapse to a single suffix.
|
||||
"""
|
||||
if not _has_full_bbox(s, w, n, e):
|
||||
return ""
|
||||
try:
|
||||
ss = math.floor(float(s))
|
||||
ww = math.floor(float(w))
|
||||
nn = math.ceil(float(n))
|
||||
ee = math.ceil(float(e))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
# If the requested window covers basically the whole world, treat it as
|
||||
# "no bbox" for caching purposes so world-zoomed clients all hit the
|
||||
# same ETag and benefit from the existing 304 path.
|
||||
lat_span, lng_span = _bbox_spans(s, w, n, e)
|
||||
if lng_span >= 300 or lat_span >= 120:
|
||||
return ""
|
||||
return f"|bbox={ss},{ww},{nn},{ee}"
|
||||
|
||||
|
||||
def _apply_bbox_to_payload(payload: dict, heavy_keys: tuple[str, ...],
|
||||
s: float, w: float, n: float, e: float) -> dict:
|
||||
"""In-place filter the heavy-key collections in *payload* to a viewport.
|
||||
|
||||
Items without lat/lng are passed through (so e.g. summary blobs aren't
|
||||
accidentally dropped). The existing _bbox_filter helper applies a 20%
|
||||
pad and handles antimeridian crossings.
|
||||
"""
|
||||
lat_span, lng_span = _bbox_spans(s, w, n, e)
|
||||
# World-scale request → skip filtering entirely. Spares the CPU and
|
||||
# guarantees the response matches the no-params shape.
|
||||
if lng_span >= 300 or lat_span >= 120:
|
||||
return payload
|
||||
for key in heavy_keys:
|
||||
items = payload.get(key)
|
||||
if not isinstance(items, list) or not items:
|
||||
continue
|
||||
payload[key] = _bbox_filter(items, s, w, n, e)
|
||||
return payload
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
@@ -561,14 +479,13 @@ async def bootstrap_critical(request: Request):
|
||||
@limiter.limit("120/minute")
|
||||
async def live_data_fast(
|
||||
request: Request,
|
||||
s: float = Query(None, description="South bound — when all four bounds are supplied, heavy/dense layers (vessels, aircraft, sigint, CCTV, …) are filtered to this viewport with 20% padding. Static reference layers (satellites, etc.) always ship world-scale.", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (see s)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (see s)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (see s)", ge=-180, le=180),
|
||||
s: float = Query(None, description="South bound (ignored)", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
|
||||
):
|
||||
bbox_suffix = _bbox_etag_suffix(s, w, n, e)
|
||||
etag = _current_etag(prefix=("fast|initial|" if initial else "fast|full|") + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
@@ -608,11 +525,6 @@ async def live_data_fast(
|
||||
payload = _cap_fast_startup_payload(payload)
|
||||
else:
|
||||
payload = _cap_fast_dashboard_payload(payload)
|
||||
# Issue #288: bbox filter heavy/dense layers only when all four bounds
|
||||
# are supplied. Without bounds, behaviour is byte-for-byte identical
|
||||
# to the pre-#288 implementation.
|
||||
if _has_full_bbox(s, w, n, e):
|
||||
payload = _apply_bbox_to_payload(payload, _FAST_BBOX_HEAVY_KEYS, s, w, n, e)
|
||||
return Response(content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@@ -621,13 +533,12 @@ async def live_data_fast(
|
||||
@limiter.limit("60/minute")
|
||||
async def live_data_slow(
|
||||
request: Request,
|
||||
s: float = Query(None, description="South bound — when all four bounds are supplied, heavy/dense layers (gdelt, firms_fires, kiwisdr, scanners, psk_reporter) are filtered to this viewport with 20% padding. Static reference layers (datacenters, military bases, power plants, weather, news, …) always ship world-scale.", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (see s)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (see s)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (see s)", ge=-180, le=180),
|
||||
s: float = Query(None, description="South bound (ignored)", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
):
|
||||
bbox_suffix = _bbox_etag_suffix(s, w, n, e)
|
||||
etag = _current_etag(prefix="slow|full|" + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
etag = _current_etag(prefix="slow|full|")
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
@@ -681,12 +592,6 @@ async def live_data_slow(
|
||||
"crowdthreat": (d.get("crowdthreat") or []) if active_layers.get("crowdthreat", True) else [],
|
||||
"freshness": freshness,
|
||||
}
|
||||
# Issue #288: bbox filter heavy/dense layers only when all four bounds
|
||||
# are supplied. Static reference layers (datacenters, military bases,
|
||||
# power_plants, etc.) deliberately stay world-scale so panning never
|
||||
# hides the infrastructure overlay the operator already has on screen.
|
||||
if _has_full_bbox(s, w, n, e):
|
||||
payload = _apply_bbox_to_payload(payload, _SLOW_BBOX_HEAVY_KEYS, s, w, n, e)
|
||||
return Response(
|
||||
content=orjson.dumps(_sanitize_payload(payload), default=str, option=orjson.OPT_NON_STR_KEYS),
|
||||
media_type="application/json",
|
||||
|
||||
+10
-87
@@ -85,30 +85,7 @@ async def api_geocode_reverse(
|
||||
return await asyncio.to_thread(reverse_geocode, lat, lng, local_only)
|
||||
|
||||
|
||||
# ── Sentinel proxy routes (Issue #299/#300/#301, reported by tg12) ──────────
|
||||
# These three endpoints relay external Sentinel / Planetary Computer
|
||||
# requests through the backend to avoid browser CORS blocks. They are
|
||||
# operator-only helpers — they MUST NOT be callable by anonymous remote
|
||||
# users, because:
|
||||
#
|
||||
# * /api/sentinel/token — caller supplies their own Sentinel client_id +
|
||||
# client_secret. Without operator gating, the backend becomes a free
|
||||
# anonymous OAuth-mint relay for any Copernicus account.
|
||||
# * /api/sentinel/tile — same shape as the token route but for tile
|
||||
# imagery. Without gating, the backend acts as an anonymous quota and
|
||||
# bandwidth relay for Sentinel Hub Process API calls.
|
||||
# * /api/sentinel2/search — hits the Planetary Computer STAC search API
|
||||
# and falls back to Esri imagery. No caller credentials are involved,
|
||||
# but the route is still an anonymous external-search relay. We gate
|
||||
# it the same way for consistency with the rest of the operator-only
|
||||
# helper surface.
|
||||
#
|
||||
# Gating is via require_local_operator (loopback / bridge / admin key),
|
||||
# matching the same allowlist already used by /api/region-dossier and
|
||||
# the other operator helpers further up this file. Single-operator nodes
|
||||
# see no behavior change — their dashboard already lives on loopback or
|
||||
# the trusted Docker bridge, so it still resolves.
|
||||
@router.get("/api/sentinel2/search", dependencies=[Depends(require_local_operator)])
|
||||
@router.get("/api/sentinel2/search")
|
||||
@limiter.limit("30/minute")
|
||||
def api_sentinel2_search(
|
||||
request: Request,
|
||||
@@ -120,60 +97,18 @@ def api_sentinel2_search(
|
||||
return search_sentinel2_scene(lat, lng)
|
||||
|
||||
|
||||
# Issue #298 (tg12): Sentinel credentials moved server-side
|
||||
# ---------------------------------------------------------------------------
|
||||
# Previously the frontend kept Copernicus CDSE client_id + client_secret in
|
||||
# browser localStorage / sessionStorage and forwarded them on every tile
|
||||
# request through this proxy. That exposed real third-party credentials to
|
||||
# any same-origin script (XSS, malicious browser extension, dev-tools HAR
|
||||
# export).
|
||||
#
|
||||
# Resolution order (first match wins):
|
||||
# 1. Request body — kept for back-compat. A small number of legacy
|
||||
# operator setups may still post credentials; we don't break them.
|
||||
# 2. Backend .env — SENTINEL_CLIENT_ID / SENTINEL_CLIENT_SECRET, managed
|
||||
# through the existing /api/settings/api-keys flow (admin-gated).
|
||||
#
|
||||
# The frontend in ``sentinelHub.ts`` no longer reads browser storage and no
|
||||
# longer forwards credentials — every dashboard request now lands in (2).
|
||||
# The require_local_operator gate (added in #303/PR #303) stays — both layers
|
||||
# are independent: the gate blocks anonymous callers, the env fallback lets
|
||||
# legitimate (gated) callers omit credentials from the body.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resolve_sentinel_credentials(body_id: str, body_secret: str) -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) using body values when present,
|
||||
otherwise falling back to backend .env. Empty strings if neither is set."""
|
||||
import os as _os
|
||||
cid = (body_id or "").strip() or (_os.environ.get("SENTINEL_CLIENT_ID", "") or "").strip()
|
||||
csec = (body_secret or "").strip() or (_os.environ.get("SENTINEL_CLIENT_SECRET", "") or "").strip()
|
||||
return cid, csec
|
||||
|
||||
|
||||
@router.post("/api/sentinel/token", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/sentinel/token")
|
||||
@limiter.limit("60/minute")
|
||||
async def api_sentinel_token(request: Request):
|
||||
"""Proxy Copernicus CDSE OAuth2 token request (avoids browser CORS block).
|
||||
|
||||
Credentials are resolved by ``_resolve_sentinel_credentials`` — body
|
||||
fields are honored for back-compat, otherwise the backend .env values
|
||||
populated through ``/api/settings/api-keys`` are used.
|
||||
"""
|
||||
"""Proxy Copernicus CDSE OAuth2 token request (avoids browser CORS block)."""
|
||||
import requests as req
|
||||
body = await request.body()
|
||||
from urllib.parse import parse_qs
|
||||
params = parse_qs(body.decode("utf-8"))
|
||||
body_id = params.get("client_id", [""])[0]
|
||||
body_secret = params.get("client_secret", [""])[0]
|
||||
client_id, client_secret = _resolve_sentinel_credentials(body_id, body_secret)
|
||||
client_id = params.get("client_id", [""])[0]
|
||||
client_secret = params.get("client_secret", [""])[0]
|
||||
if not client_id or not client_secret:
|
||||
# Friendly, non-hostile error — points the operator at the place
|
||||
# they configure other API keys instead of just saying "required".
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Sentinel client_id/client_secret are not configured. "
|
||||
"Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET in the "
|
||||
"API Keys panel (Settings → API Keys) or your backend .env.",
|
||||
)
|
||||
raise HTTPException(400, "client_id and client_secret required")
|
||||
token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
|
||||
try:
|
||||
resp = await asyncio.to_thread(req.post, token_url,
|
||||
@@ -217,7 +152,7 @@ import os as _os
|
||||
_SH_TOKEN_CACHE_HMAC_KEY = _os.urandom(32)
|
||||
|
||||
|
||||
@router.post("/api/sentinel/tile", dependencies=[Depends(require_local_operator)])
|
||||
@router.post("/api/sentinel/tile")
|
||||
@limiter.limit("300/minute")
|
||||
async def api_sentinel_tile(request: Request):
|
||||
"""Proxy Sentinel Hub Process API tile request (avoids CORS block)."""
|
||||
@@ -228,11 +163,8 @@ async def api_sentinel_tile(request: Request):
|
||||
except Exception:
|
||||
return JSONResponse(status_code=422, content={"ok": False, "detail": "invalid JSON body"})
|
||||
|
||||
# Issue #298: same resolution order as /api/sentinel/token — body
|
||||
# values for back-compat, otherwise backend .env.
|
||||
body_id = body.get("client_id", "")
|
||||
body_secret = body.get("client_secret", "")
|
||||
client_id, client_secret = _resolve_sentinel_credentials(body_id, body_secret)
|
||||
client_id = body.get("client_id", "")
|
||||
client_secret = body.get("client_secret", "")
|
||||
preset = body.get("preset", "TRUE-COLOR")
|
||||
date_str = body.get("date", "")
|
||||
z = body.get("z", 0)
|
||||
@@ -240,16 +172,7 @@ async def api_sentinel_tile(request: Request):
|
||||
y = body.get("y", 0)
|
||||
|
||||
if not client_id or not client_secret or not date_str:
|
||||
# Distinguish "no creds" from "no date" so the operator knows
|
||||
# what to fix. Same friendly pointer as the /token route.
|
||||
if not client_id or not client_secret:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Sentinel client_id/client_secret are not configured. "
|
||||
"Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET in the "
|
||||
"API Keys panel (Settings → API Keys) or your backend .env.",
|
||||
)
|
||||
raise HTTPException(400, "date required")
|
||||
raise HTTPException(400, "client_id, client_secret, and date required")
|
||||
|
||||
now = _time.time()
|
||||
credential_fp = _credential_fingerprint(client_id, client_secret)
|
||||
|
||||
@@ -150,31 +150,6 @@ API_REGISTRY = [
|
||||
"url": "https://finnhub.io/register",
|
||||
"required": False,
|
||||
},
|
||||
# Issue #298 (tg12): Sentinel Hub / Copernicus Data Space Ecosystem
|
||||
# credentials were previously held in browser localStorage / sessionStorage
|
||||
# by the Settings panel. Moved server-side to the same .env-backed
|
||||
# store every other third-party API key lives in. The Sentinel proxy
|
||||
# routes (POST /api/sentinel/token, /tile) now fall back to these
|
||||
# env values when the request body omits credentials — see
|
||||
# backend/routers/tools.py for the resolution order.
|
||||
{
|
||||
"id": "sentinel_client_id",
|
||||
"env_key": "SENTINEL_CLIENT_ID",
|
||||
"name": "Sentinel Hub / Copernicus — Client ID",
|
||||
"description": "OAuth2 client ID for Copernicus Data Space Ecosystem (CDSE). Required for the Sentinel-2 imagery overlay and the right-click Sentinel-2 Intel Card. Sign in at dataspace.copernicus.eu and create OAuth credentials.",
|
||||
"category": "Imagery",
|
||||
"url": "https://dataspace.copernicus.eu/",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"id": "sentinel_client_secret",
|
||||
"env_key": "SENTINEL_CLIENT_SECRET",
|
||||
"name": "Sentinel Hub / Copernicus — Client Secret",
|
||||
"description": "OAuth2 client secret paired with the Client ID above. Used by the backend to mint short-lived access tokens against the CDSE identity provider. Stored in the backend .env; never sent to the browser.",
|
||||
"category": "Imagery",
|
||||
"url": "https://dataspace.copernicus.eu/",
|
||||
"required": False,
|
||||
},
|
||||
]
|
||||
|
||||
ALLOWED_ENV_KEYS = {
|
||||
|
||||
@@ -89,34 +89,6 @@ import pytest
|
||||
# relay through the backend. 60/minute rate limit is not enough on
|
||||
# a streaming endpoint.
|
||||
("get", "/api/radio/openmhz/audio?url=https%3A%2F%2Fmedia.openmhz.com%2Faudio%2Fabc.mp3", None),
|
||||
# Issue #299 (tg12): /api/sentinel/token relays Copernicus CDSE
|
||||
# OAuth token requests for caller-supplied client_id/secret.
|
||||
# Anonymous access turns the backend into a free OAuth-mint relay.
|
||||
(
|
||||
"post",
|
||||
"/api/sentinel/token",
|
||||
None, # body sent via raw form-encoded data — None lets the
|
||||
# remote_client wrapper send an empty body; the auth
|
||||
# check fires before the form parser runs.
|
||||
),
|
||||
# Issue #300 (tg12): /api/sentinel/tile relays Sentinel Hub Process
|
||||
# API tile fetches. Anonymous access is a bandwidth/quota relay
|
||||
# for any caller's Copernicus account.
|
||||
(
|
||||
"post",
|
||||
"/api/sentinel/tile",
|
||||
{
|
||||
"client_id": "ignored",
|
||||
"client_secret": "ignored",
|
||||
"preset": "TRUE-COLOR",
|
||||
"date": "2026-01-01",
|
||||
"z": 6, "x": 30, "y": 20,
|
||||
},
|
||||
),
|
||||
# Issue #301 (tg12): /api/sentinel2/search hits Planetary Computer
|
||||
# STAC + Esri fallback. Anonymous access is a free external-search
|
||||
# relay even though no caller credentials are involved.
|
||||
("get", "/api/sentinel2/search?lat=0&lng=0", None),
|
||||
],
|
||||
)
|
||||
def test_remote_control_surface_rejects_without_local_operator_or_admin(
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Tests for issue #288: viewport bbox filtering on /api/live-data/{fast,slow}.
|
||||
|
||||
Behaviour contract:
|
||||
* Without s/w/n/e params, the response is byte-for-byte identical to the
|
||||
pre-#288 implementation. (No filtering, no extra fields, no ETag change.)
|
||||
* With s/w/n/e supplied, heavy/dense layers are filtered to that viewport
|
||||
with a 20% padding box.
|
||||
* Light reference layers (datacenters, military_bases, power_plants,
|
||||
satellites, news, weather, …) are NEVER filtered, even when bounds are
|
||||
supplied — panning must never reveal an "empty world" of infrastructure.
|
||||
* World-scale bounds (lng_span >= 300 OR lat_span >= 120) short-circuit
|
||||
filtering and share the global ETag.
|
||||
* The ETag includes a 1°-quantized bbox so two viewports never poison each
|
||||
other's 304 cache.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ───────────────────────── /api/live-data/fast ─────────────────────────────
|
||||
|
||||
|
||||
class TestFastBboxFiltering:
|
||||
def _seed_fast(self, monkeypatch):
|
||||
"""Plant deterministic heavy + light fixtures across the globe."""
|
||||
from services.fetchers import _store
|
||||
|
||||
# Heavy collections: dense across the world.
|
||||
commercial = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "f-sw"}, # south Pacific
|
||||
{"lat": 35.0, "lng": -75.0, "id": "f-ne"}, # eastern US
|
||||
{"lat": 35.0, "lng": 100.0, "id": "f-asia"}, # Asia
|
||||
]
|
||||
ships = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "s-sw"},
|
||||
{"lat": 35.0, "lng": -75.0, "id": "s-ne"},
|
||||
]
|
||||
cctv = [{"lat": 35.0, "lng": -75.0, "id": "c-1"}]
|
||||
|
||||
# Sigint heavy collection.
|
||||
sigint = [
|
||||
{"source": "meshtastic", "lat": 35.0, "lng": -75.0, "id": "sig-east"},
|
||||
{"source": "meshtastic", "lat": 35.0, "lng": 100.0, "id": "sig-asia"},
|
||||
]
|
||||
|
||||
# Light/reference layer — must NEVER be filtered.
|
||||
satellites = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "sat-sw"},
|
||||
{"lat": 35.0, "lng": -75.0, "id": "sat-ne"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "sat-asia"},
|
||||
]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "commercial_flights", commercial)
|
||||
monkeypatch.setitem(_store.latest_data, "ships", ships)
|
||||
monkeypatch.setitem(_store.latest_data, "cctv", cctv)
|
||||
monkeypatch.setitem(_store.latest_data, "sigint", sigint)
|
||||
monkeypatch.setitem(_store.latest_data, "satellites", satellites)
|
||||
# Ensure all layers are on so the response includes them.
|
||||
for layer in (
|
||||
"flights", "ships_military", "ships_cargo", "ships_civilian",
|
||||
"ships_passenger", "ships_tracked_yachts", "cctv",
|
||||
"sigint_meshtastic", "sigint_aprs", "satellites",
|
||||
):
|
||||
monkeypatch.setitem(_store.active_layers, layer, True)
|
||||
|
||||
def test_no_bbox_returns_world_data(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r = client.get("/api/live-data/fast")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# All heavy fixtures pass through unchanged.
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
assert len(data["ships"]) == 2
|
||||
assert len(data["sigint"]) == 2
|
||||
# Light layer also full.
|
||||
assert len(data["satellites"]) == 3
|
||||
|
||||
def test_bbox_filters_heavy_layers(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Box tightly around the eastern-US fixture (lat 35, lng -75).
|
||||
# ±5° → after 20% padding inside _bbox_filter, ~±6° window.
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# Heavy layers: only the eastern-US fixture survives.
|
||||
assert {f["id"] for f in data["commercial_flights"]} == {"f-ne"}
|
||||
assert {s["id"] for s in data["ships"]} == {"s-ne"}
|
||||
assert {c["id"] for c in data["cctv"]} == {"c-1"}
|
||||
assert {s["id"] for s in data["sigint"]} == {"sig-east"}
|
||||
|
||||
def test_bbox_does_not_filter_light_layers(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# Satellites are a reference layer — must NOT be bbox-filtered.
|
||||
assert len(data["satellites"]) == 3
|
||||
|
||||
def test_world_scale_bbox_skips_filtering(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# lng_span = 360 → treated as world-scale; same as no bbox.
|
||||
r = client.get("/api/live-data/fast?s=-90&w=-180&n=90&e=180")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
assert len(data["ships"]) == 2
|
||||
|
||||
def test_partial_bbox_is_treated_as_no_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Only three of four bounds → filtering must NOT engage.
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
|
||||
def test_etag_changes_with_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r_world = client.get("/api/live-data/fast")
|
||||
r_local = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r_world.status_code == 200
|
||||
assert r_local.status_code == 200
|
||||
etag_world = r_world.headers.get("etag")
|
||||
etag_local = r_local.headers.get("etag")
|
||||
assert etag_world and etag_local
|
||||
assert etag_world != etag_local, (
|
||||
"ETag must differ between world and regional bbox to prevent "
|
||||
"304 cache poisoning across viewports"
|
||||
)
|
||||
|
||||
def test_etag_stable_for_subdegree_pan(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Sub-degree pan should land in the same 1°-quantized bucket.
|
||||
r_a = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
r_b = client.get("/api/live-data/fast?s=30.3&w=-79.8&n=39.7&e=-70.4")
|
||||
assert r_a.headers.get("etag") == r_b.headers.get("etag")
|
||||
|
||||
def test_if_none_match_returns_304_for_same_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r1 = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
etag = r1.headers.get("etag")
|
||||
r2 = client.get(
|
||||
"/api/live-data/fast?s=30&w=-80&n=40&e=-70",
|
||||
headers={"If-None-Match": etag},
|
||||
)
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
# ───────────────────────── /api/live-data/slow ─────────────────────────────
|
||||
|
||||
|
||||
class TestSlowBboxFiltering:
|
||||
def _seed_slow(self, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
|
||||
# Heavy collections.
|
||||
gdelt = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "g-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "g-asia"},
|
||||
]
|
||||
firms_fires = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "fire-east"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "fire-ido"},
|
||||
]
|
||||
# Light/reference layers — must always ship in full.
|
||||
datacenters = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "dc-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "dc-asia"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "dc-ido"},
|
||||
]
|
||||
military_bases = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "mb-east"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "mb-ido"},
|
||||
]
|
||||
power_plants = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "pp-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "pp-asia"},
|
||||
]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "gdelt", gdelt)
|
||||
monkeypatch.setitem(_store.latest_data, "firms_fires", firms_fires)
|
||||
monkeypatch.setitem(_store.latest_data, "datacenters", datacenters)
|
||||
monkeypatch.setitem(_store.latest_data, "military_bases", military_bases)
|
||||
monkeypatch.setitem(_store.latest_data, "power_plants", power_plants)
|
||||
for layer in (
|
||||
"global_incidents", "firms", "datacenters", "military_bases", "power_plants",
|
||||
):
|
||||
monkeypatch.setitem(_store.active_layers, layer, True)
|
||||
|
||||
def test_no_bbox_returns_world_data(self, client, monkeypatch):
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["gdelt"]) == 2
|
||||
assert len(data["firms_fires"]) == 2
|
||||
assert len(data["datacenters"]) == 3
|
||||
|
||||
def test_bbox_filters_heavy_layers(self, client, monkeypatch):
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert {g["id"] for g in data["gdelt"]} == {"g-east"}
|
||||
assert {f["id"] for f in data["firms_fires"]} == {"fire-east"}
|
||||
|
||||
def test_bbox_leaves_reference_layers_untouched(self, client, monkeypatch):
|
||||
"""Datacenters, bases, and power plants are infrastructure overlays —
|
||||
they must remain world-scale so panning never hides them."""
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["datacenters"]) == 3
|
||||
assert len(data["military_bases"]) == 2
|
||||
assert len(data["power_plants"]) == 2
|
||||
|
||||
def test_antimeridian_bbox(self, client, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
# Box that straddles the antimeridian (Pacific): w=170, e=-170.
|
||||
gdelt = [
|
||||
{"lat": 0.0, "lng": 175.0, "id": "in-west"},
|
||||
{"lat": 0.0, "lng": -175.0, "id": "in-east"},
|
||||
{"lat": 0.0, "lng": 0.0, "id": "out-mid"},
|
||||
]
|
||||
monkeypatch.setitem(_store.latest_data, "gdelt", gdelt)
|
||||
monkeypatch.setitem(_store.active_layers, "global_incidents", True)
|
||||
r = client.get("/api/live-data/slow?s=-10&w=170&n=10&e=-170")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
ids = {g["id"] for g in data["gdelt"]}
|
||||
assert "in-west" in ids
|
||||
assert "in-east" in ids
|
||||
assert "out-mid" not in ids
|
||||
|
||||
|
||||
# ─────────────────── Direct helper coverage (defensive) ─────────────────────
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_has_full_bbox(self):
|
||||
from routers.data import _has_full_bbox
|
||||
assert _has_full_bbox(1, 2, 3, 4)
|
||||
assert not _has_full_bbox(None, 2, 3, 4)
|
||||
assert not _has_full_bbox(1, None, 3, 4)
|
||||
assert not _has_full_bbox(1, 2, None, 4)
|
||||
assert not _has_full_bbox(1, 2, 3, None)
|
||||
|
||||
def test_bbox_etag_suffix_quantizes(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
a = _bbox_etag_suffix(30.1, -79.6, 39.9, -70.1)
|
||||
b = _bbox_etag_suffix(30.4, -79.2, 39.4, -70.8)
|
||||
assert a == b, "Sub-degree pan must collapse to the same ETag suffix"
|
||||
assert a.startswith("|bbox=")
|
||||
|
||||
def test_bbox_etag_suffix_world_collapses(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
# World-scale → empty suffix (shares the global ETag).
|
||||
assert _bbox_etag_suffix(-90, -180, 90, 180) == ""
|
||||
|
||||
def test_bbox_etag_suffix_partial_is_empty(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
assert _bbox_etag_suffix(None, -180, 90, 180) == ""
|
||||
|
||||
def test_apply_bbox_preserves_non_list_values(self):
|
||||
from routers.data import _apply_bbox_to_payload, _FAST_BBOX_HEAVY_KEYS
|
||||
payload = {
|
||||
"commercial_flights": [{"lat": 35, "lng": -75, "id": "x"}],
|
||||
"satellite_source": "tle", # not a list, must pass through
|
||||
"sigint_totals": {"total": 1}, # dict — must pass through
|
||||
}
|
||||
out = _apply_bbox_to_payload(dict(payload), _FAST_BBOX_HEAVY_KEYS, 30, -80, 40, -70)
|
||||
assert out["satellite_source"] == "tle"
|
||||
assert out["sigint_totals"] == {"total": 1}
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Tests for issue #287: proxy-aware slowapi key function.
|
||||
|
||||
Contract:
|
||||
* Untrusted peer → key is the peer IP (matches old get_remote_address).
|
||||
* Trusted frontend peer with X-Forwarded-For → key is first XFF entry.
|
||||
* Trusted frontend peer without X-Forwarded-For → key is the peer IP
|
||||
(fail-soft: no behaviour change vs. before #287).
|
||||
* XFF from an untrusted peer is IGNORED — there must be no way to
|
||||
spoof another operator's bucket by sending XFF directly.
|
||||
* The first XFF entry is used (not the last — that's the trusted
|
||||
proxy talking to the backend, not the actual operator).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, host: str):
|
||||
self.host = host
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
"""Minimal slowapi-compatible request shim — has ``client`` and
|
||||
``headers`` attributes, which is all the key_func touches."""
|
||||
|
||||
def __init__(self, client_host: str, headers: dict | None = None):
|
||||
self.client = _FakeClient(client_host) if client_host is not None else None
|
||||
self.headers = dict(headers or {})
|
||||
# slowapi's get_remote_address also tries request.client; we
|
||||
# exercise both branches via the same shim.
|
||||
|
||||
|
||||
# ───────────────────────── untrusted peers ──────────────────────────────
|
||||
|
||||
|
||||
class TestUntrustedPeer:
|
||||
def test_direct_loopback_uses_client_host(self, monkeypatch):
|
||||
"""Direct hit from 127.0.0.1 — no XFF — keys on the peer IP."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
# Make sure the trusted-frontend cache resolves to nothing relevant.
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset())
|
||||
req = _FakeRequest("127.0.0.1")
|
||||
assert shadowbroker_rate_limit_key(req) == "127.0.0.1"
|
||||
|
||||
def test_xff_from_untrusted_peer_is_ignored(self, monkeypatch):
|
||||
"""A random caller sending X-Forwarded-For must NOT steal another
|
||||
operator's bucket. The XFF is dropped on the floor."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
# Trusted set deliberately does NOT include 1.2.3.4.
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest("1.2.3.4", {"X-Forwarded-For": "9.9.9.9"})
|
||||
# Falls back to the peer IP, not 9.9.9.9.
|
||||
assert shadowbroker_rate_limit_key(req) == "1.2.3.4"
|
||||
|
||||
def test_unknown_host_with_xff_uses_peer_host(self, monkeypatch):
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset())
|
||||
req = _FakeRequest("10.0.0.5", {"X-Forwarded-For": "1.1.1.1"})
|
||||
assert shadowbroker_rate_limit_key(req) == "10.0.0.5"
|
||||
|
||||
|
||||
# ───────────────────────── trusted frontend peers ───────────────────────
|
||||
|
||||
|
||||
class TestTrustedFrontendPeer:
|
||||
def test_trusted_peer_with_xff_uses_first_xff_entry(self, monkeypatch):
|
||||
"""When the immediate peer is the trusted frontend container and
|
||||
XFF carries the operator's chain, we key on the operator."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest("172.20.0.5", {"X-Forwarded-For": "203.0.113.7"})
|
||||
assert shadowbroker_rate_limit_key(req) == "203.0.113.7"
|
||||
|
||||
def test_first_xff_entry_picked_in_chain(self, monkeypatch):
|
||||
"""`client, proxy1, proxy2` → we pick the client, not the proxies.
|
||||
Picking the last entry would mean every operator behind the same
|
||||
upstream gets bucketed together, which is the bug we're fixing."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest(
|
||||
"172.20.0.5",
|
||||
{"X-Forwarded-For": "203.0.113.7, 198.51.100.1, 10.0.0.1"},
|
||||
)
|
||||
assert shadowbroker_rate_limit_key(req) == "203.0.113.7"
|
||||
|
||||
def test_trusted_peer_without_xff_falls_back_to_peer(self, monkeypatch):
|
||||
"""If the trusted frontend forgot to forward XFF (legacy clients,
|
||||
broken deploys), don't crash — bucket on the bridge IP exactly
|
||||
like the pre-#287 behaviour."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest("172.20.0.5", headers={})
|
||||
assert shadowbroker_rate_limit_key(req) == "172.20.0.5"
|
||||
|
||||
def test_trusted_peer_with_empty_xff_falls_back(self, monkeypatch):
|
||||
"""``X-Forwarded-For: , ,`` → no usable entries → falls back."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest("172.20.0.5", {"X-Forwarded-For": " , , "})
|
||||
assert shadowbroker_rate_limit_key(req) == "172.20.0.5"
|
||||
|
||||
def test_xff_header_case_insensitive(self, monkeypatch):
|
||||
"""HTTP header names are case-insensitive — slowapi normalises
|
||||
but our shim doesn't, so we explicitly check both forms."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
req = _FakeRequest("172.20.0.5", {"x-forwarded-for": "203.0.113.7"})
|
||||
assert shadowbroker_rate_limit_key(req) == "203.0.113.7"
|
||||
|
||||
|
||||
# ───────────────────────── isolation guarantees ─────────────────────────
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
def test_two_operators_behind_same_proxy_get_different_keys(self, monkeypatch):
|
||||
"""The whole reason this fix exists — two operators behind the
|
||||
SAME proxy must end up in DIFFERENT buckets."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
op_a = _FakeRequest("172.20.0.5", {"X-Forwarded-For": "10.1.1.1"})
|
||||
op_b = _FakeRequest("172.20.0.5", {"X-Forwarded-For": "10.1.1.2"})
|
||||
key_a = shadowbroker_rate_limit_key(op_a)
|
||||
key_b = shadowbroker_rate_limit_key(op_b)
|
||||
assert key_a != key_b
|
||||
assert key_a == "10.1.1.1"
|
||||
assert key_b == "10.1.1.2"
|
||||
|
||||
def test_no_xff_spoof_from_outside(self, monkeypatch):
|
||||
"""If we ever expose the backend port directly to the internet,
|
||||
an attacker MUST NOT be able to steal another operator's bucket
|
||||
by sending their own XFF header."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
# Trusted set is the frontend container IP; the attacker is on a
|
||||
# different (untrusted) IP and tries to spoof a victim's IP.
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset({"172.20.0.5"}))
|
||||
attacker = _FakeRequest("203.0.113.66", {"X-Forwarded-For": "10.1.1.1"})
|
||||
victim_via_proxy = _FakeRequest("172.20.0.5", {"X-Forwarded-For": "10.1.1.1"})
|
||||
assert shadowbroker_rate_limit_key(attacker) == "203.0.113.66"
|
||||
assert shadowbroker_rate_limit_key(victim_via_proxy) == "10.1.1.1"
|
||||
# The attacker burning their own bucket doesn't touch the victim's.
|
||||
assert shadowbroker_rate_limit_key(attacker) != shadowbroker_rate_limit_key(
|
||||
victim_via_proxy
|
||||
)
|
||||
|
||||
def test_limiter_object_uses_proxy_aware_key(self):
|
||||
"""Smoke check that the module-level Limiter exports the new key
|
||||
function rather than slowapi's default."""
|
||||
from limiter import limiter, shadowbroker_rate_limit_key
|
||||
# slowapi stores it as ._key_func; we don't want to depend on
|
||||
# that internal name, so just check the function is reachable.
|
||||
assert callable(shadowbroker_rate_limit_key)
|
||||
assert limiter is not None
|
||||
|
||||
|
||||
# ───────────────────────── defensive corners ────────────────────────────
|
||||
|
||||
|
||||
class TestDefensive:
|
||||
def test_no_client_object(self, monkeypatch):
|
||||
"""Some upstream middleware paths (websocket, ASGI lifespan)
|
||||
produce requests with no ``client`` attribute — must not raise."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", lambda: frozenset())
|
||||
|
||||
class _NoClient:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.headers = {}
|
||||
|
||||
# slowapi's get_remote_address returns "127.0.0.1" as a default
|
||||
# in this case, so we just ensure no exception escapes.
|
||||
result = shadowbroker_rate_limit_key(_NoClient())
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_resolver_raises_is_treated_as_untrusted(self, monkeypatch):
|
||||
"""If DNS blows up inside the trusted-bridge resolver, we MUST
|
||||
fall back to peer IP — never accept XFF blindly."""
|
||||
from limiter import shadowbroker_rate_limit_key
|
||||
|
||||
def _explode():
|
||||
raise RuntimeError("DNS down")
|
||||
|
||||
monkeypatch.setattr("auth._resolve_trusted_bridge_ips", _explode)
|
||||
req = _FakeRequest("172.20.0.5", {"X-Forwarded-For": "9.9.9.9"})
|
||||
# XFF must be ignored when we can't confirm peer is trusted.
|
||||
assert shadowbroker_rate_limit_key(req) == "172.20.0.5"
|
||||
@@ -1,277 +0,0 @@
|
||||
"""Issue #298 (tg12): Sentinel credentials must live server-side.
|
||||
|
||||
Before the fix, ``frontend/src/components/SettingsPanel.tsx`` stored
|
||||
``client_id`` and ``client_secret`` in ``localStorage`` /
|
||||
``sessionStorage`` via the privacy storage helper, and the proxy routes
|
||||
in ``backend/routers/tools.py`` REQUIRED those values to come in the
|
||||
request body. Any same-origin script (XSS, malicious extension,
|
||||
dev-tools HAR export) had read access to real third-party Sentinel
|
||||
credentials.
|
||||
|
||||
After the fix:
|
||||
|
||||
* ``SENTINEL_CLIENT_ID`` and ``SENTINEL_CLIENT_SECRET`` are entries
|
||||
in the ``api_settings.API_REGISTRY`` and are persisted via the
|
||||
existing ``/api/settings/api-keys`` flow (admin-gated, .env-backed,
|
||||
never returned to the browser).
|
||||
* The proxy routes prefer request-body values for back-compat but
|
||||
fall back to ``os.environ.get("SENTINEL_CLIENT_ID")`` /
|
||||
``os.environ.get("SENTINEL_CLIENT_SECRET")`` when the body omits
|
||||
them. The dashboard's ``sentinelHub.ts`` no longer sends credentials
|
||||
in the body — every request now hits the env path.
|
||||
* When neither source has a value, the route returns a 400 with a
|
||||
pointer to the API Keys panel rather than a curt "client_id and
|
||||
client_secret required" message.
|
||||
|
||||
These tests cover the resolution order and the registry surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: import the routes module fresh per test so monkey-patched
|
||||
# environment variables are picked up by the route's os.environ.get call.
|
||||
# (The lookup is per-request, not at import time, so this isn't strictly
|
||||
# required — but it makes the test layout obvious.)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
"""ASGI client with peer IP 127.0.0.1 so the Sentinel routes' (post-#303)
|
||||
``require_local_operator`` gate passes.
|
||||
|
||||
Built without a context manager so the privacy-core lifespan check
|
||||
doesn't run in the test env.
|
||||
"""
|
||||
import asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from main import app
|
||||
|
||||
class _Loop:
|
||||
def __init__(self):
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._transport = ASGITransport(app=app, client=("127.0.0.1", 12345))
|
||||
self._base = "http://127.0.0.1:8000"
|
||||
|
||||
def _do(self, method: str, url: str, **kw):
|
||||
async def go():
|
||||
async with AsyncClient(transport=self._transport, base_url=self._base) as ac:
|
||||
return await ac.request(method, url, **kw)
|
||||
return self._loop.run_until_complete(go())
|
||||
|
||||
def get(self, url, **kw): return self._do("GET", url, **kw)
|
||||
def post(self, url, **kw): return self._do("POST", url, **kw)
|
||||
def put(self, url, **kw): return self._do("PUT", url, **kw)
|
||||
|
||||
def close(self): self._loop.close()
|
||||
|
||||
c = _Loop()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API_REGISTRY surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApiRegistry:
|
||||
def test_sentinel_keys_registered(self):
|
||||
"""Both Sentinel keys must be entries in API_REGISTRY so the
|
||||
existing /api/settings/api-keys PUT flow can write them to .env."""
|
||||
from services.api_settings import API_REGISTRY, ALLOWED_ENV_KEYS
|
||||
|
||||
ids = {row["id"] for row in API_REGISTRY}
|
||||
assert "sentinel_client_id" in ids
|
||||
assert "sentinel_client_secret" in ids
|
||||
|
||||
# Critical: ALLOWED_ENV_KEYS is the gate on which .env keys the
|
||||
# API can mutate. If we forgot to add the env_key field on the
|
||||
# registry rows, callers couldn't actually save the values.
|
||||
assert "SENTINEL_CLIENT_ID" in ALLOWED_ENV_KEYS
|
||||
assert "SENTINEL_CLIENT_SECRET" in ALLOWED_ENV_KEYS
|
||||
|
||||
def test_api_keys_put_accepts_sentinel_keys(self, loopback_client, monkeypatch, tmp_path):
|
||||
"""End-to-end: PUT /api/settings/api-keys with SENTINEL_CLIENT_ID
|
||||
+ SENTINEL_CLIENT_SECRET must persist to .env."""
|
||||
import services.api_settings as api_settings
|
||||
|
||||
# Redirect both .env paths to tmp so the test doesn't mutate
|
||||
# the developer's real backend .env.
|
||||
tmp_env = tmp_path / ".env"
|
||||
monkeypatch.setattr(api_settings, "ENV_PATH", tmp_env)
|
||||
monkeypatch.setattr(api_settings, "OPERATOR_KEYS_ENV_PATH", tmp_path / "operator_api_keys.env")
|
||||
|
||||
r = loopback_client.put(
|
||||
"/api/settings/api-keys",
|
||||
json={
|
||||
"SENTINEL_CLIENT_ID": "test-sentinel-id",
|
||||
"SENTINEL_CLIENT_SECRET": "test-sentinel-secret",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, f"PUT failed: {r.text}"
|
||||
body = r.json()
|
||||
assert body.get("ok") is True
|
||||
|
||||
# File on disk should now carry both keys.
|
||||
parsed = api_settings._parse_env_file(tmp_env)
|
||||
assert parsed.get("SENTINEL_CLIENT_ID") == "test-sentinel-id"
|
||||
assert parsed.get("SENTINEL_CLIENT_SECRET") == "test-sentinel-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential resolution — body wins, env is fallback, neither is 400
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSentinelTokenCredResolution:
|
||||
def test_env_fallback_when_body_empty(self, loopback_client, monkeypatch):
|
||||
"""No body credentials → backend reads .env values."""
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id")
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret")
|
||||
|
||||
# Mock the upstream Copernicus call so we don't hit the network.
|
||||
# Capture what was sent so we can prove env values were used.
|
||||
captured: dict = {}
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status_code = 200
|
||||
fake_resp.content = b'{"access_token": "stub", "expires_in": 300}'
|
||||
|
||||
def fake_post(url, *args, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["data"] = kwargs.get("data", {})
|
||||
return fake_resp
|
||||
|
||||
with patch("requests.post", side_effect=fake_post):
|
||||
r = loopback_client.post(
|
||||
"/api/sentinel/token",
|
||||
data={}, # ← deliberately empty body
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
# The forwarded creds must come from env, not from a stale cache
|
||||
# or fallback string.
|
||||
assert captured.get("data", {}).get("client_id") == "env-id"
|
||||
assert captured.get("data", {}).get("client_secret") == "env-secret"
|
||||
|
||||
def test_body_credentials_win_over_env(self, loopback_client, monkeypatch):
|
||||
"""Body values (back-compat path) must win when both sources
|
||||
are present. This preserves the pre-#298 behavior for any
|
||||
legacy callers that still post credentials."""
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id")
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret")
|
||||
|
||||
captured: dict = {}
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status_code = 200
|
||||
fake_resp.content = b'{"access_token": "stub"}'
|
||||
|
||||
def fake_post(url, *args, **kwargs):
|
||||
captured["data"] = kwargs.get("data", {})
|
||||
return fake_resp
|
||||
|
||||
with patch("requests.post", side_effect=fake_post):
|
||||
r = loopback_client.post(
|
||||
"/api/sentinel/token",
|
||||
data={"client_id": "body-id", "client_secret": "body-secret"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured["data"]["client_id"] == "body-id"
|
||||
assert captured["data"]["client_secret"] == "body-secret"
|
||||
|
||||
def test_400_when_neither_source_has_credentials(self, loopback_client, monkeypatch):
|
||||
"""If body is empty AND env is empty, return 400 with a
|
||||
friendly pointer to the API Keys panel — not a curt
|
||||
"required" message and not a 500."""
|
||||
monkeypatch.delenv("SENTINEL_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("SENTINEL_CLIENT_SECRET", raising=False)
|
||||
|
||||
# If the route ever calls requests.post here, the gate is broken
|
||||
# — empty creds should never produce an outbound HTTP call.
|
||||
fake = MagicMock(side_effect=AssertionError(
|
||||
"requests.post should not be called when no credentials are configured"
|
||||
))
|
||||
with patch("requests.post", fake):
|
||||
r = loopback_client.post(
|
||||
"/api/sentinel/token",
|
||||
data={},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
detail = r.json().get("detail", "")
|
||||
# The pointer to the API Keys panel is what makes this non-hostile.
|
||||
assert "API Keys panel" in detail or "SENTINEL_CLIENT_ID" in detail
|
||||
assert fake.call_count == 0
|
||||
|
||||
|
||||
class TestSentinelTileCredResolution:
|
||||
def test_env_fallback_when_body_omits_credentials(self, loopback_client, monkeypatch):
|
||||
"""Tile route: no body credentials → uses env values."""
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_ID", "env-id")
|
||||
monkeypatch.setenv("SENTINEL_CLIENT_SECRET", "env-secret")
|
||||
|
||||
token_resp = MagicMock()
|
||||
token_resp.status_code = 200
|
||||
token_resp.json = MagicMock(return_value={"access_token": "stub", "expires_in": 300})
|
||||
|
||||
process_resp = MagicMock()
|
||||
process_resp.status_code = 200
|
||||
process_resp.content = b"<png bytes>"
|
||||
process_resp.headers = {"content-type": "image/png"}
|
||||
|
||||
captured: list = []
|
||||
|
||||
def fake_post(url, *args, **kwargs):
|
||||
captured.append({"url": url, "data": kwargs.get("data"), "json": kwargs.get("json")})
|
||||
if "openid-connect/token" in url:
|
||||
return token_resp
|
||||
return process_resp
|
||||
|
||||
with patch("requests.post", side_effect=fake_post):
|
||||
r = loopback_client.post(
|
||||
"/api/sentinel/tile",
|
||||
json={
|
||||
# Note: no client_id / client_secret in body
|
||||
"preset": "TRUE-COLOR",
|
||||
"date": "2026-01-01",
|
||||
"z": 6, "x": 30, "y": 20,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
# First call was the token mint; verify it used env creds.
|
||||
token_call = next(c for c in captured if "openid-connect/token" in c["url"])
|
||||
assert token_call["data"]["client_id"] == "env-id"
|
||||
assert token_call["data"]["client_secret"] == "env-secret"
|
||||
|
||||
def test_400_when_neither_source_has_credentials(self, loopback_client, monkeypatch):
|
||||
monkeypatch.delenv("SENTINEL_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("SENTINEL_CLIENT_SECRET", raising=False)
|
||||
|
||||
fake = MagicMock(side_effect=AssertionError(
|
||||
"requests.post should not be called when no credentials are configured"
|
||||
))
|
||||
with patch("requests.post", fake):
|
||||
r = loopback_client.post(
|
||||
"/api/sentinel/tile",
|
||||
json={
|
||||
"preset": "TRUE-COLOR",
|
||||
"date": "2026-01-01",
|
||||
"z": 6, "x": 30, "y": 20,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
detail = r.json().get("detail", "")
|
||||
assert "API Keys panel" in detail or "SENTINEL_CLIENT_ID" in detail
|
||||
assert fake.call_count == 0
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Issues #299, #300, #301 (tg12): Sentinel proxy routes must require
|
||||
local-operator auth.
|
||||
|
||||
Before the fix, three Sentinel proxy routes in ``backend/routers/tools.py``
|
||||
were decorated only with ``@limiter.limit(...)`` — no
|
||||
``Depends(require_local_operator)``:
|
||||
|
||||
* ``POST /api/sentinel/token`` — Copernicus CDSE OAuth relay for
|
||||
caller-supplied client_id + client_secret. Anonymous access made the
|
||||
backend a free OAuth-mint relay for any Sentinel account.
|
||||
* ``POST /api/sentinel/tile`` — Sentinel Hub Process API relay.
|
||||
Caller supplies their own credentials, backend mints a token if
|
||||
needed and relays the PNG. Anonymous access was a bandwidth + quota
|
||||
relay for any Copernicus account.
|
||||
* ``GET /api/sentinel2/search`` — Planetary Computer STAC search with
|
||||
Esri imagery fallback. No caller credentials are involved, but the
|
||||
route is still an anonymous external-search relay.
|
||||
|
||||
The fix adds ``dependencies=[Depends(require_local_operator)]`` to each.
|
||||
The parameterized regression in ``test_control_surface_auth.py`` covers
|
||||
the basic 403 path. This file adds the harder property: when the auth
|
||||
gate fires, **the underlying upstream HTTP call never happens** — no
|
||||
outbound Copernicus token mint, no Sentinel Hub Process call, no
|
||||
Planetary Computer STAC search. The egress-on-403 property is what
|
||||
separates a real gate from a route that returns 403 *after* burning a
|
||||
quota.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote client fixture — same shape as test_control_surface_auth.py, but
|
||||
# inlined here so this file doesn't depend on the shared remote_client
|
||||
# fixture order. Uses 1.2.3.4 as the peer IP so loopback auth bypass
|
||||
# doesn't accidentally let the request through.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _PeerClient:
|
||||
"""Raw ASGI client with a configurable peer IP. FastAPI's
|
||||
``TestClient`` reports ``request.client.host`` as ``"testclient"``
|
||||
which isn't on the loopback allowlist — we need to set the peer
|
||||
explicitly to exercise the real ``require_local_operator`` path.
|
||||
"""
|
||||
|
||||
def __init__(self, peer_ip: str):
|
||||
from main import app
|
||||
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._transport = ASGITransport(app=app, client=(peer_ip, 12345))
|
||||
self._base = f"http://{peer_ip}:8000"
|
||||
|
||||
def _do(self, method: str, url: str, **kw):
|
||||
async def go():
|
||||
async with AsyncClient(transport=self._transport, base_url=self._base) as ac:
|
||||
return await ac.request(method, url, **kw)
|
||||
|
||||
return self._loop.run_until_complete(go())
|
||||
|
||||
def get(self, url, **kw):
|
||||
return self._do("GET", url, **kw)
|
||||
|
||||
def post(self, url, **kw):
|
||||
return self._do("POST", url, **kw)
|
||||
|
||||
def close(self):
|
||||
self._loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote():
|
||||
"""Untrusted remote caller (1.2.3.4) — must hit the auth gate."""
|
||||
client = _PeerClient("1.2.3.4")
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback():
|
||||
"""127.0.0.1 caller — must pass the gate exactly like the operator."""
|
||||
client = _PeerClient("127.0.0.1")
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sentinel/token — issue #299
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSentinelTokenAuthGate:
|
||||
def test_anonymous_caller_is_rejected(self, remote):
|
||||
"""A remote (non-loopback, non-bridge) caller MUST be rejected."""
|
||||
r = remote.post(
|
||||
"/api/sentinel/token",
|
||||
data={"client_id": "anything", "client_secret": "anything"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_no_upstream_token_mint_on_403(self, remote):
|
||||
"""The Copernicus token endpoint must NOT be contacted when the
|
||||
auth gate fires. This is what makes the gate real — without it,
|
||||
a 403 returned *after* the upstream call still burns quota.
|
||||
|
||||
We patch ``requests.post`` at the module level so any outbound
|
||||
token request would be intercepted. The mock is asserted to have
|
||||
ZERO calls.
|
||||
"""
|
||||
fake_post = MagicMock()
|
||||
# If the gate is broken, the route would call requests.post; we
|
||||
# want this MagicMock to make that fact loud.
|
||||
fake_post.side_effect = AssertionError(
|
||||
"requests.post was called despite auth-gate 403 — the gate is bypassable"
|
||||
)
|
||||
with patch("requests.post", fake_post):
|
||||
r = remote.post(
|
||||
"/api/sentinel/token",
|
||||
data={"client_id": "anything", "client_secret": "anything"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert fake_post.call_count == 0
|
||||
|
||||
def test_loopback_caller_passes_auth(self, loopback):
|
||||
"""A 127.0.0.1 caller must pass the gate. We don't care about
|
||||
the upstream response shape — just that the request reaches the
|
||||
handler (which would then try to talk to Copernicus). We patch
|
||||
``requests.post`` to return a 401 so the test doesn't hit the
|
||||
real network.
|
||||
|
||||
Note: FastAPI's ``TestClient`` reports ``request.client.host``
|
||||
as ``"testclient"`` by default, which is NOT on the loopback
|
||||
allowlist (``127.0.0.1`` / ``::1`` / ``localhost``). The
|
||||
``loopback`` fixture below uses raw ASGI with an explicit
|
||||
``127.0.0.1`` peer IP so the auth gate sees real loopback.
|
||||
"""
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.status_code = 401
|
||||
fake_resp.content = b'{"error": "invalid_client"}'
|
||||
with patch("requests.post", return_value=fake_resp):
|
||||
r = loopback.post(
|
||||
"/api/sentinel/token",
|
||||
data={"client_id": "anything", "client_secret": "anything"},
|
||||
)
|
||||
# 200 (relayed), 401 (upstream said no), or 502 (upstream blew up)
|
||||
# are all acceptable — what matters is we got past the auth gate
|
||||
# (no 403). The route relays the upstream response status.
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sentinel/tile — issue #300
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSentinelTileAuthGate:
|
||||
_VALID_BODY = {
|
||||
"client_id": "anything",
|
||||
"client_secret": "anything",
|
||||
"preset": "TRUE-COLOR",
|
||||
"date": "2026-01-01",
|
||||
"z": 6,
|
||||
"x": 30,
|
||||
"y": 20,
|
||||
}
|
||||
|
||||
def test_anonymous_caller_is_rejected(self, remote):
|
||||
r = remote.post("/api/sentinel/tile", json=self._VALID_BODY)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_no_upstream_call_on_403(self, remote):
|
||||
"""When the gate fires, neither the token mint nor the Process
|
||||
API call should happen."""
|
||||
fake_post = MagicMock(side_effect=AssertionError(
|
||||
"requests.post was called despite auth-gate 403 — gate bypassable"
|
||||
))
|
||||
with patch("requests.post", fake_post):
|
||||
r = remote.post("/api/sentinel/tile", json=self._VALID_BODY)
|
||||
assert r.status_code == 403
|
||||
assert fake_post.call_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/sentinel2/search — issue #301
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSentinel2SearchAuthGate:
|
||||
def test_anonymous_caller_is_rejected(self, remote):
|
||||
r = remote.get("/api/sentinel2/search?lat=0&lng=0")
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_no_upstream_search_on_403(self, remote):
|
||||
"""The Planetary Computer STAC search MUST NOT be called when
|
||||
the gate fires."""
|
||||
fake = MagicMock(side_effect=AssertionError(
|
||||
"search_sentinel2_scene was called despite 403 — gate bypassable"
|
||||
))
|
||||
# Patch the underlying service function — that's the network
|
||||
# surface. If the auth dep fires first, the handler body never
|
||||
# runs and this stays uncalled.
|
||||
with patch("services.sentinel_search.search_sentinel2_scene", fake):
|
||||
r = remote.get("/api/sentinel2/search?lat=0&lng=0")
|
||||
assert r.status_code == 403
|
||||
assert fake.call_count == 0
|
||||
|
||||
def test_loopback_caller_reaches_handler(self, loopback):
|
||||
"""127.0.0.1 must pass the gate and reach the search function.
|
||||
Uses raw ASGI peer IP via the ``loopback`` fixture — TestClient
|
||||
would set ``request.client.host`` to ``"testclient"`` which
|
||||
isn't on the loopback allowlist."""
|
||||
fake = MagicMock(return_value={"ok": True, "results": []})
|
||||
with patch("services.sentinel_search.search_sentinel2_scene", fake):
|
||||
r = loopback.get("/api/sentinel2/search?lat=0&lng=0")
|
||||
assert r.status_code == 200
|
||||
assert fake.call_count == 1
|
||||
|
||||
|
||||
# Note: an earlier draft included a static dependency walker that
|
||||
# inspected the FastAPI route table to assert require_local_operator
|
||||
# was wired in. It was deleted because FastAPI's internal route
|
||||
# representation varies across minor versions — the walker was brittle
|
||||
# and the behavioral pair (anonymous → 403 with no upstream egress;
|
||||
# loopback → handler reached) gives stronger end-to-end evidence than
|
||||
# any structural check.
|
||||
@@ -842,7 +842,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
expect(screen.queryByText(/delivery key has not reached/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes an approved contact immediately from the visible contact list', { timeout: 30_000 }, async () => {
|
||||
it('removes an approved contact immediately from the visible contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_remove': {
|
||||
alias: 'Remove Me',
|
||||
@@ -865,49 +865,21 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
|
||||
// The Remove handler dispatches several React state updates in one
|
||||
// event:
|
||||
// removeContact(peerId) — external mutation (mock deletes
|
||||
// from contactsState)
|
||||
// setContacts(updater) — React state update
|
||||
// setComposeStatus(`Removed — toast text, computed via
|
||||
// contact: ${displayNameForPeer displayNameForPeer(peerId, contacts)
|
||||
// (peerId, contacts)}.`) which reads the CLOSED-OVER
|
||||
// contacts state
|
||||
//
|
||||
// The flake history (PRs #226, #237, #261, #262, #265, #294, #303,
|
||||
// #304, plus the fd7d6fa push) has two distinct causes:
|
||||
//
|
||||
// (a) CI runner starvation — two parallel ci.yml invocations
|
||||
// (direct + workflow_call from docker-publish.yml) starving
|
||||
// each other on the same Actions runner. Fixed structurally
|
||||
// in .github/workflows/ci.yml via a concurrency group.
|
||||
//
|
||||
// (b) Alias-resolution race — under certain renders, the closed
|
||||
// -over `contacts` in the Remove handler can see the post-
|
||||
// mutation state (contact already gone), and
|
||||
// displayNameForPeer falls through to return the raw peer
|
||||
// id ("!sb_remove") rather than the alias ("Remove Me").
|
||||
// The toast then renders as "Removed contact: !sb_remove."
|
||||
// which the precise `/Removed contact: Remove Me\./i` regex
|
||||
// missed. We loosen the assertion to match either rendering
|
||||
// — the behavioural guarantee under test is "the removal
|
||||
// toast appears", not "the alias was resolved correctly
|
||||
// at toast-render time". That second property is an
|
||||
// implementation detail the component can reorder freely.
|
||||
//
|
||||
// The pair of assertions below still proves the real contract:
|
||||
// 1. A toast that announces a removal renders.
|
||||
// 2. The contact's alias is no longer visible in the contact list.
|
||||
//
|
||||
// The failure mode this no longer masks is "no toast at all", which
|
||||
// still fails loudly at the 10s waitFor cap.
|
||||
// event (removeContact + setContacts + setComposeStatus + setComposeError).
|
||||
// Under CI load the resulting render-and-paint cycle has been observed
|
||||
// to take >1s, which is the default findByText timeout — that race has
|
||||
// produced flakes on PRs #226, #237, #261, and #262 in succession.
|
||||
// The settle window is bounded by React's reconciliation, not by any
|
||||
// network/animation cost, so a generous timeout is the right deflake
|
||||
// here (the failure mode this masks would be "toast never renders",
|
||||
// which would still fail at 5s).
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.getByText(/Removed contact:/i),
|
||||
screen.getByText(/Removed contact: Remove Me\./i),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000, interval: 50 },
|
||||
{ timeout: 5000, interval: 50 },
|
||||
);
|
||||
expect(screen.queryByText('Remove Me')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Issue #298 (tg12): Sentinel credentials must no longer live in browser
|
||||
* storage, and the proxy calls must not forward them in request bodies.
|
||||
* These tests pin both invariants on ``lib/sentinelHub``:
|
||||
*
|
||||
* 1. ``migrateLegacySentinelBrowserKeys()`` clears the legacy keys
|
||||
* idempotently and reports what it cleared.
|
||||
* 2. ``fetchSentinelTile()`` and ``getSentinelToken()`` POST WITHOUT
|
||||
* ``client_id`` or ``client_secret`` in the body — the backend
|
||||
* resolves credentials from its ``.env``. A future refactor that
|
||||
* accidentally re-introduces browser-storage reads (e.g. by
|
||||
* restoring ``getSentinelCredentials()`` and forwarding it) gets a
|
||||
* loud test failure here rather than a silent privacy regression.
|
||||
* 3. ``checkBackendSentinelStatus()`` queries ``/api/settings/api-keys``
|
||||
* and returns true only when both Sentinel keys report ``is_set``.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
migrateLegacySentinelBrowserKeys,
|
||||
fetchSentinelTile,
|
||||
getSentinelToken,
|
||||
checkBackendSentinelStatus,
|
||||
refreshSentinelStatus,
|
||||
} from '@/lib/sentinelHub';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe('lib/sentinelHub — issue #298 server-side credentials', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
refreshSentinelStatus();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
refreshSentinelStatus();
|
||||
});
|
||||
|
||||
describe('migrateLegacySentinelBrowserKeys', () => {
|
||||
it('clears legacy localStorage keys and reports what it cleared', () => {
|
||||
window.localStorage.setItem('sb_sentinel_client_id', 'sh-leaked-id');
|
||||
window.localStorage.setItem('sb_sentinel_client_secret', 'leaked-secret');
|
||||
window.localStorage.setItem('sb_sentinel_instance_id', 'leaked-instance');
|
||||
|
||||
const result = migrateLegacySentinelBrowserKeys();
|
||||
|
||||
expect(window.localStorage.getItem('sb_sentinel_client_id')).toBeNull();
|
||||
expect(window.localStorage.getItem('sb_sentinel_client_secret')).toBeNull();
|
||||
expect(window.localStorage.getItem('sb_sentinel_instance_id')).toBeNull();
|
||||
expect(result.cleared.sort()).toEqual([
|
||||
'sb_sentinel_client_id',
|
||||
'sb_sentinel_client_secret',
|
||||
'sb_sentinel_instance_id',
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('clears sessionStorage too (privacy-strict mode used to put them there)', () => {
|
||||
window.sessionStorage.setItem('sb_sentinel_client_id', 'sh-session-id');
|
||||
window.sessionStorage.setItem('sb_sentinel_client_secret', 'session-secret');
|
||||
|
||||
const result = migrateLegacySentinelBrowserKeys();
|
||||
|
||||
expect(window.sessionStorage.getItem('sb_sentinel_client_id')).toBeNull();
|
||||
expect(window.sessionStorage.getItem('sb_sentinel_client_secret')).toBeNull();
|
||||
expect(result.cleared).toContain('sb_sentinel_client_id');
|
||||
expect(result.cleared).toContain('sb_sentinel_client_secret');
|
||||
});
|
||||
|
||||
it('is idempotent — calling it on a clean store reports nothing cleared', () => {
|
||||
const result = migrateLegacySentinelBrowserKeys();
|
||||
expect(result.cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy requests no longer forward credentials', () => {
|
||||
it('fetchSentinelTile POSTs without client_id/client_secret in the body', async () => {
|
||||
// Plant credentials in browser storage to prove they would NOT be
|
||||
// picked up even if present. Pre-#298, this would have been read
|
||||
// from localStorage and posted in the body.
|
||||
window.localStorage.setItem('sb_sentinel_client_id', 'sh-leaked-id');
|
||||
window.localStorage.setItem('sb_sentinel_client_secret', 'leaked-secret');
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response(new ArrayBuffer(0), { status: 200 }));
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
await fetchSentinelTile(6, 30, 20, 'TRUE-COLOR', '2026-01-01');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body).not.toHaveProperty('client_id');
|
||||
expect(body).not.toHaveProperty('client_secret');
|
||||
// Sanity: the legitimate fields are still there.
|
||||
expect(body).toMatchObject({ preset: 'TRUE-COLOR', date: '2026-01-01', z: 6, x: 30, y: 20 });
|
||||
});
|
||||
|
||||
it('getSentinelToken POSTs with an empty form body (backend uses env)', async () => {
|
||||
window.localStorage.setItem('sb_sentinel_client_id', 'sh-leaked-id');
|
||||
window.localStorage.setItem('sb_sentinel_client_secret', 'leaked-secret');
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ access_token: 'stub', expires_in: 300 }), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const token = await getSentinelToken();
|
||||
|
||||
expect(token).toBe('stub');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
const body = String(init.body);
|
||||
// Body is a URLSearchParams stringification. We assert that the
|
||||
// leaked credential never appears in it.
|
||||
expect(body).not.toContain('sh-leaked-id');
|
||||
expect(body).not.toContain('leaked-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBackendSentinelStatus', () => {
|
||||
it('returns true when both Sentinel keys report is_set on /api/settings/api-keys', async () => {
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/api/settings/api-keys')) {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{ id: 'sentinel_client_id', env_key: 'SENTINEL_CLIENT_ID', is_set: true },
|
||||
{ id: 'sentinel_client_secret', env_key: 'SENTINEL_CLIENT_SECRET', is_set: true },
|
||||
{ id: 'opensky_client_id', env_key: 'OPENSKY_CLIENT_ID', is_set: false },
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const configured = await checkBackendSentinelStatus();
|
||||
expect(configured).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when only one of the two keys is set', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{ id: 'sentinel_client_id', env_key: 'SENTINEL_CLIENT_ID', is_set: true },
|
||||
{ id: 'sentinel_client_secret', env_key: 'SENTINEL_CLIENT_SECRET', is_set: false },
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const configured = await checkBackendSentinelStatus();
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
|
||||
it('fails safely (false) when the backend errors', async () => {
|
||||
const fetchMock = vi.fn(async () => { throw new Error('network down'); });
|
||||
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||
|
||||
const configured = await checkBackendSentinelStatus();
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
hasSentinelInfoBeenSeen,
|
||||
markSentinelInfoSeen,
|
||||
hasSentinelCredentials,
|
||||
checkBackendSentinelStatus,
|
||||
} from '@/lib/sentinelHub';
|
||||
import { useTranslation } from '@/i18n';
|
||||
import { LocateBar } from './LocateBar';
|
||||
@@ -108,15 +107,6 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sb_ticker_open', tickerOpen.toString());
|
||||
}, [tickerOpen]);
|
||||
|
||||
// Issue #298: kick the one-time backend Sentinel-status check on mount.
|
||||
// This populates the cached value that ``hasSentinelCredentials()`` reads
|
||||
// synchronously elsewhere (MaplibreViewer's tile-URL memo, the
|
||||
// Sentinel-info modal flow). Fire-and-forget — the cache stays false
|
||||
// until resolved so the UI fails safely.
|
||||
useEffect(() => {
|
||||
void checkBackendSentinelStatus();
|
||||
}, []);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [legendOpen, setLegendOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
|
||||
@@ -74,18 +74,17 @@ import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Satellite,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Check,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
// Issue #298: Sentinel credentials now live server-side. The legacy
|
||||
// browser-storage helpers (getSentinelCredentials / setSentinelCredentials
|
||||
// / clearSentinelCredentials / getSentinelCredentialStorageMode) have
|
||||
// been removed from sentinelHub.ts. We use the new status check + the
|
||||
// one-time migration helper instead.
|
||||
checkBackendSentinelStatus,
|
||||
migrateLegacySentinelBrowserKeys,
|
||||
clearSentinelCredentials,
|
||||
getSentinelCredentialStorageMode,
|
||||
getSentinelCredentials,
|
||||
setSentinelCredentials,
|
||||
} from '@/lib/sentinelHub';
|
||||
import {
|
||||
getPrivacyProfilePreference,
|
||||
@@ -144,14 +143,10 @@ const WEIGHT_COLORS: Record<number, string> = {
|
||||
const SETTINGS_FOCUS_KEY = 'sb_settings_focus';
|
||||
const WORMHOLE_RETURN_KEY = 'sb_wormhole_return_target';
|
||||
const WORMHOLE_READY_EVENT = 'sb:wormhole-ready';
|
||||
// Issue #298 (tg12): Sentinel credentials moved from browser storage to
|
||||
// the backend ``.env`` (managed through the API Keys panel). The legacy
|
||||
// keys (``sb_sentinel_client_id`` / ``sb_sentinel_client_secret`` /
|
||||
// ``sb_sentinel_instance_id``) are no longer treated as sensitive
|
||||
// browser state because they are no longer written. ``SentinelTab``
|
||||
// runs ``migrateLegacySentinelBrowserKeys()`` once on mount to clear
|
||||
// any leftover values from pre-#298 installs.
|
||||
const PRIVACY_SENSITIVE_BROWSER_KEYS = [
|
||||
'sb_sentinel_client_id',
|
||||
'sb_sentinel_client_secret',
|
||||
'sb_sentinel_instance_id',
|
||||
'sb_infonet_head',
|
||||
'sb_infonet_head_history',
|
||||
'sb_infonet_peers',
|
||||
@@ -2620,9 +2615,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
)}
|
||||
|
||||
{/* ==================== SENTINEL HUB TAB ==================== */}
|
||||
{activeTab === 'sentinel' && (
|
||||
<SentinelTab onGoToApiKeys={() => setActiveTab('api-keys')} />
|
||||
)}
|
||||
{activeTab === 'sentinel' && <SentinelTab />}
|
||||
{activeTab === 'sar' && <SarSettingsTab />}
|
||||
</motion.div>
|
||||
</>
|
||||
@@ -2632,58 +2625,63 @@ const SettingsPanel = React.memo(function SettingsPanel({
|
||||
});
|
||||
|
||||
// ─── Sentinel Hub Settings Tab ─────────────────────────────────────────────
|
||||
// Issue #298 (tg12): Sentinel credentials now live in the backend ``.env``
|
||||
// and are managed through the existing API Keys panel — same flow as every
|
||||
// other third-party API key (OpenSky, AIS Stream, Finnhub, …). This tab no
|
||||
// longer collects credentials. It does three things:
|
||||
// 1. Runs migrateLegacySentinelBrowserKeys() once to wipe pre-#298
|
||||
// values out of localStorage / sessionStorage.
|
||||
// 2. Shows the operator whether the backend has the credentials.
|
||||
// 3. Offers a one-click jump to the API Keys panel where they enter them.
|
||||
function SentinelTab({ onGoToApiKeys }: { onGoToApiKeys: () => void }) {
|
||||
const [backendConfigured, setBackendConfigured] = useState<boolean | null>(null);
|
||||
const [migrationResult, setMigrationResult] = useState<{ cleared: string[] } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
function SentinelTab() {
|
||||
const [clientId, setClientId] = useState(() => getSentinelCredentials().clientId);
|
||||
const [clientSecret, setClientSecret] = useState(() => getSentinelCredentials().clientSecret);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [status, setStatus] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const storageMode = getSentinelCredentialStorageMode();
|
||||
|
||||
useEffect(() => {
|
||||
// One-time legacy browser-key wipe. Idempotent — does nothing on a
|
||||
// fresh install. We do NOT silently POST any browser-stored values
|
||||
// to the backend; operators who relied on them re-enter once in the
|
||||
// API Keys panel. Doing the wipe regardless ensures pre-#298 secrets
|
||||
// don't linger in localStorage indefinitely.
|
||||
setMigrationResult(migrateLegacySentinelBrowserKeys());
|
||||
const save = () => {
|
||||
setSentinelCredentials(clientId.trim(), clientSecret.trim());
|
||||
setDirty(false);
|
||||
setStatus({
|
||||
ok: true,
|
||||
msg: `Credentials saved to browser ${storageMode === 'session' ? 'session' : 'local'} storage.`,
|
||||
});
|
||||
};
|
||||
|
||||
// Check whether the backend has SENTINEL_CLIENT_ID/SECRET set.
|
||||
void checkBackendSentinelStatus().then(setBackendConfigured);
|
||||
}, []);
|
||||
|
||||
const refresh = async () => {
|
||||
setRefreshing(true);
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
// refreshSentinelStatus() invalidates the module-level cache so the
|
||||
// next check actually hits the backend instead of returning the
|
||||
// memoized value. Lazy-imported so SSR/tests don't choke.
|
||||
const { refreshSentinelStatus } = await import('@/lib/sentinelHub');
|
||||
refreshSentinelStatus();
|
||||
const ok = await checkBackendSentinelStatus();
|
||||
setBackendConfigured(ok);
|
||||
const resp = await fetch(`${API_BASE}/api/sentinel/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId.trim(),
|
||||
client_secret: clientSecret.trim(),
|
||||
}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
setStatus({ ok: true, msg: 'Connected — token acquired successfully.' });
|
||||
} else {
|
||||
const text = await resp.text().catch(() => '');
|
||||
setStatus({ ok: false, msg: `Auth failed (${resp.status}): ${text.slice(0, 120)}` });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown';
|
||||
setStatus({ ok: false, msg: `Network error: ${msg}` });
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor =
|
||||
backendConfigured === null
|
||||
? 'text-[var(--text-muted)]'
|
||||
: backendConfigured
|
||||
? 'text-green-400'
|
||||
: 'text-yellow-400';
|
||||
const statusLabel =
|
||||
backendConfigured === null
|
||||
? 'CHECKING…'
|
||||
: backendConfigured
|
||||
? 'CONFIGURED ON BACKEND'
|
||||
: 'NOT CONFIGURED';
|
||||
const clear = () => {
|
||||
clearSentinelCredentials();
|
||||
setClientId('');
|
||||
setClientSecret('');
|
||||
setDirty(false);
|
||||
setStatus({ ok: true, msg: 'Credentials cleared.' });
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-[var(--bg-primary)]/60 border border-[var(--border-primary)] px-3 py-2 text-[11px] font-mono text-[var(--text-secondary)] outline-none focus:border-purple-500 placeholder:text-[var(--text-muted)]/50 transition-colors';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto styled-scrollbar">
|
||||
@@ -2735,73 +2733,106 @@ function SentinelTab({ onGoToApiKeys }: { onGoToApiKeys: () => void }) {
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-purple-400 font-bold">STEP 3:</span>{' '}
|
||||
Paste both values into the <span className="text-cyan-400">API Keys</span> panel
|
||||
under <span className="text-white">SENTINEL_CLIENT_ID</span> and{' '}
|
||||
<span className="text-white">SENTINEL_CLIENT_SECRET</span>, then hit Save.
|
||||
The backend uses them to mint short-lived tokens — your browser never sees
|
||||
the secret again.
|
||||
Paste both values in the fields below, hit{' '}
|
||||
<span className="text-cyan-400">SAVE</span>, then{' '}
|
||||
<span className="text-cyan-400">TEST CONNECTION</span> to verify.
|
||||
That's it!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backend status */}
|
||||
<div className="mx-4 mt-3 p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest">
|
||||
BACKEND STATUS
|
||||
</span>
|
||||
<span className={`text-[11px] font-mono font-bold ${statusColor}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{/* Credential Inputs */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest mb-1 block">
|
||||
CLIENT ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={clientId}
|
||||
onChange={(e) => {
|
||||
setClientId(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder="sh-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-[var(--text-muted)] font-mono leading-relaxed">
|
||||
{backendConfigured === false
|
||||
? 'Sentinel credentials are not yet set in the backend .env. Open the API Keys panel to enter them — the tile overlay and Sentinel-2 Intel Card will work as soon as both fields are saved.'
|
||||
: backendConfigured === true
|
||||
? 'Sentinel credentials are configured on the backend. The dashboard fetches tokens automatically; your browser does not handle the secret.'
|
||||
: 'Checking backend configuration…'}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div>
|
||||
<label className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest mb-1 block">
|
||||
CLIENT SECRET
|
||||
</label>
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={clientSecret}
|
||||
onChange={(e) => {
|
||||
setClientSecret(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
placeholder="Paste client secret here..."
|
||||
spellCheck={false}
|
||||
autoComplete="new-password"
|
||||
className={inputCls}
|
||||
/>
|
||||
<button
|
||||
onClick={onGoToApiKeys}
|
||||
className="flex-1 px-4 py-2 bg-purple-500/20 border border-purple-500/40 text-purple-400 hover:bg-purple-500/30 transition-colors text-sm font-mono flex items-center justify-center gap-1.5"
|
||||
type="button"
|
||||
onClick={() => setShowSecret((current) => !current)}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-[13px] font-mono text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
OPEN API KEYS PANEL
|
||||
</button>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={refreshing}
|
||||
className="px-3 py-2 border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm font-mono disabled:opacity-40"
|
||||
title="Re-check backend status"
|
||||
>
|
||||
{refreshing ? 'CHECKING…' : 'REFRESH'}
|
||||
{showSecret ? <EyeOff size={10} /> : <Eye size={10} />}
|
||||
{showSecret ? 'HIDE SECRET' : 'SHOW SECRET'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Migration notice (only if we actually cleared anything) */}
|
||||
{migrationResult && migrationResult.cleared.length > 0 && (
|
||||
<div className="mx-4 mt-3 px-3 py-2 text-sm font-mono text-cyan-400 bg-cyan-950/20 border border-cyan-900/30">
|
||||
<p className="font-bold mb-1">LEGACY BROWSER CREDENTIALS CLEARED</p>
|
||||
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">
|
||||
Found and removed pre-#298 Sentinel credentials from browser storage
|
||||
({migrationResult.cleared.join(', ')}). Re-enter them in the API Keys panel
|
||||
above; they'll be stored server-side from now on and never sent back to
|
||||
the browser.
|
||||
</p>
|
||||
{/* Status */}
|
||||
{status && (
|
||||
<div
|
||||
className={`mx-4 mb-2 px-3 py-2 text-sm font-mono ${status.ok ? 'text-green-400 bg-green-950/20 border border-green-900/30' : 'text-red-400 bg-red-950/20 border border-red-900/30'}`}
|
||||
>
|
||||
{status.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer + Usage Meter */}
|
||||
{/* Actions */}
|
||||
<div className="p-4 border-t border-[var(--border-primary)]/80 mt-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty}
|
||||
className="flex-1 px-4 py-2 bg-purple-500/20 border border-purple-500/40 text-purple-400 hover:bg-purple-500/30 transition-colors text-sm font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={10} />
|
||||
SAVE
|
||||
</button>
|
||||
<button
|
||||
onClick={testConnection}
|
||||
disabled={testing || !clientId || !clientSecret}
|
||||
className="flex-1 px-4 py-2 bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-sm font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{testing ? 'TESTING...' : 'TEST CONNECTION'}
|
||||
</button>
|
||||
<button
|
||||
onClick={clear}
|
||||
className="px-3 py-2 border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-red-400 hover:border-red-500/50 hover:bg-red-950/10 transition-all text-sm font-mono flex items-center gap-1.5"
|
||||
title="Clear credentials"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Usage Meter */}
|
||||
<UsageMeter />
|
||||
|
||||
<div className="mt-2 p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/30">
|
||||
<p className="text-[13px] text-[var(--text-muted)] font-mono leading-relaxed">
|
||||
Credentials are stored in the backend <span className="text-cyan-400">.env</span>{' '}
|
||||
and never sent to the browser. The tile proxy mints short-lived OAuth tokens
|
||||
on demand using those values.
|
||||
Credentials stay in browser-only storage and never touch ShadowBroker servers.
|
||||
{storageMode === 'session'
|
||||
? ' Current privacy mode keeps them in session storage only.'
|
||||
: ' Current privacy mode keeps them in local storage for persistence.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
normalizeViewBounds,
|
||||
type ViewBounds,
|
||||
} from '@/lib/viewportPrivacy';
|
||||
import { setLiveDataBounds } from '@/lib/liveDataViewport';
|
||||
|
||||
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
|
||||
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
|
||||
@@ -71,17 +70,6 @@ export function useViewportBounds(
|
||||
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
|
||||
}
|
||||
|
||||
// Issue #288: hand the same coarsened/expanded bounds to the live-data
|
||||
// poller so heavy collections in /api/live-data/{fast,slow} can be
|
||||
// scoped to the visible region. Static reference layers are unaffected
|
||||
// — see backend _FAST_BBOX_HEAVY_KEYS / _SLOW_BBOX_HEAVY_KEYS.
|
||||
setLiveDataBounds({
|
||||
south: preloadBounds.south,
|
||||
west: preloadBounds.west,
|
||||
north: preloadBounds.north,
|
||||
east: preloadBounds.east,
|
||||
});
|
||||
|
||||
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
|
||||
import { appendLiveDataBoundsParams } from "@/lib/liveDataViewport";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
@@ -33,8 +32,8 @@ export async function forceRefreshLiveData(): Promise<void> {
|
||||
|
||||
try {
|
||||
const [fastRes, slowRes] = await Promise.all([
|
||||
fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/fast`)),
|
||||
fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`)),
|
||||
fetch(`${API_BASE}/api/live-data/fast`),
|
||||
fetch(`${API_BASE}/api/live-data/slow`),
|
||||
]);
|
||||
|
||||
if (fastRes.ok) {
|
||||
@@ -86,13 +85,9 @@ export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
|
||||
/**
|
||||
* Polls the backend for fast and slow data tiers.
|
||||
*
|
||||
* Issue #288: heavy, density-driven layers (vessels, aircraft, gdelt
|
||||
* events, fires, sigint, …) are bbox-scoped to the visible map area via
|
||||
* `appendLiveDataBoundsParams`. Static reference layers (datacenters,
|
||||
* military bases, power plants, satellites, weather, news, …) are NOT
|
||||
* filtered backend-side, so panning never reveals an "empty world" of
|
||||
* infrastructure. World-zoomed views skip bbox params entirely and hit
|
||||
* the shared ETag cache exactly like the pre-#288 behaviour.
|
||||
* All data is fetched globally (no bbox filtering) — the backend returns its
|
||||
* full in-memory cache and MapLibre culls off-screen entities on the GPU.
|
||||
* This eliminates the "empty map when zooming out" lag.
|
||||
*
|
||||
* The AIS stream viewport POST (/api/viewport) is still handled separately
|
||||
* by useViewportBounds to limit upstream AIS ingestion.
|
||||
@@ -152,9 +147,7 @@ export function useDataPolling() {
|
||||
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
|
||||
const headers: Record<string, string> = {};
|
||||
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const url = appendLiveDataBoundsParams(
|
||||
`${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`,
|
||||
);
|
||||
const url = `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`;
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -200,13 +193,10 @@ export function useDataPolling() {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(
|
||||
appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`),
|
||||
{
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Shared module-level state for the current map viewport bounds, used by
|
||||
* `useDataPolling` to scope `/api/live-data/{fast,slow}` to the visible
|
||||
* area when the user has zoomed in.
|
||||
*
|
||||
* Issue #288: the backend now bbox-filters dense layers (vessels, aircraft,
|
||||
* gdelt events, fires, sigint, …) when all four bounds are supplied. Light
|
||||
* reference layers stay world-scale. Heavy collections aren't sent over the
|
||||
* wire for parts of the planet the operator isn't looking at, which cuts
|
||||
* the steady-state poll from ~27 MB to ~5 MB for a typical regional view.
|
||||
*
|
||||
* No bounds set → callers omit the params entirely → backend ships full
|
||||
* world data (byte-identical to pre-#288 behaviour). This keeps the cold
|
||||
* boot path (where no map is mounted yet) and the world-zoomed view
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
export interface LiveDataBounds {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
}
|
||||
|
||||
let _current: LiveDataBounds | null = null;
|
||||
|
||||
/** True when lng_span ≥ 300 OR lat_span ≥ 120. Backend treats these as
|
||||
* world-scale and skips filtering — so the frontend doesn't bother sending
|
||||
* bounds at all, which keeps the ETag cache shared across operators in the
|
||||
* zoomed-out case. */
|
||||
function isEffectivelyWorld(bounds: LiveDataBounds): boolean {
|
||||
const latSpan = Math.max(0, bounds.north - bounds.south);
|
||||
let lngSpan = bounds.east - bounds.west;
|
||||
if (lngSpan < 0) lngSpan += 360;
|
||||
return lngSpan >= 300 || latSpan >= 120;
|
||||
}
|
||||
|
||||
/** Push the latest committed bounds. Called from `useViewportBounds`
|
||||
* whenever the map's bounds change enough to matter. Pass `null` to
|
||||
* fall back to world-scale fetching (e.g. on unmount). */
|
||||
export function setLiveDataBounds(bounds: LiveDataBounds | null): void {
|
||||
if (bounds === null) {
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(bounds.south) ||
|
||||
!Number.isFinite(bounds.west) ||
|
||||
!Number.isFinite(bounds.north) ||
|
||||
!Number.isFinite(bounds.east)
|
||||
) {
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
if (isEffectivelyWorld(bounds)) {
|
||||
// World-zoomed → fetch globally, share the ETag cache across operators.
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
_current = bounds;
|
||||
}
|
||||
|
||||
/** Read the current bounds, or `null` if the caller should fetch the full
|
||||
* world payload. Reader contract: must tolerate `null` and call without
|
||||
* bbox params in that case. */
|
||||
export function getLiveDataBounds(): LiveDataBounds | null {
|
||||
return _current;
|
||||
}
|
||||
|
||||
/** Append `s/w/n/e` query params to a URL when bounds are set, otherwise
|
||||
* return the URL unchanged. Centralised so all live-data callers stay in
|
||||
* sync about quantization and the world-scale skip rule. */
|
||||
export function appendLiveDataBoundsParams(url: string): string {
|
||||
const b = _current;
|
||||
if (!b) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
// Match backend ETag quantization (1° floor/ceil) so the client and
|
||||
// server agree on which bounds round to the same cache key.
|
||||
const s = Math.floor(b.south);
|
||||
const w = Math.floor(b.west);
|
||||
const n = Math.ceil(b.north);
|
||||
const e = Math.ceil(b.east);
|
||||
return `${url}${sep}s=${s}&w=${w}&n=${n}&e=${e}`;
|
||||
}
|
||||
+70
-121
@@ -1,137 +1,77 @@
|
||||
/**
|
||||
* Sentinel Hub (Copernicus CDSE) — client-side token + Process API tile fetcher.
|
||||
* Sentinel Hub (Copernicus CDSE) — client-side token management & Process API tile fetcher.
|
||||
*
|
||||
* Issue #298 (tg12): Credentials are now stored server-side in the backend
|
||||
* ``.env`` (managed through the existing ``/api/settings/api-keys`` flow,
|
||||
* same as every other third-party API key). The browser no longer holds
|
||||
* ``client_id`` / ``client_secret`` in localStorage or sessionStorage and
|
||||
* no longer forwards them in proxy requests.
|
||||
* Credentials are stored in browser-controlled storage only. In privacy/session
|
||||
* mode they stay session-scoped; otherwise they persist in local storage. Token
|
||||
* exchange is proxied through the ShadowBroker backend (/api/sentinel/token) to
|
||||
* avoid CORS blocks from the Copernicus identity provider. Credentials are
|
||||
* forwarded, never stored server-side.
|
||||
*
|
||||
* Old browser-storage keys (``sb_sentinel_client_id`` / ``sb_sentinel_client_secret``
|
||||
* / ``sb_sentinel_instance_id``) are migrated out by ``SettingsPanel`` on
|
||||
* first mount after the upgrade — see ``migrateLegacySentinelBrowserKeys()``
|
||||
* exported below.
|
||||
* Uses the Process API with inline evalscripts — no Instance ID / Configuration needed.
|
||||
*/
|
||||
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import {
|
||||
getSensitiveBrowserItem,
|
||||
getSensitiveBrowserStorageMode,
|
||||
removeSensitiveBrowserItem,
|
||||
setSensitiveBrowserItem,
|
||||
} from '@/lib/privacyBrowserStorage';
|
||||
|
||||
// Token exchange proxied through our backend (Copernicus blocks browser CORS).
|
||||
// Token exchange proxied through our backend (Copernicus blocks browser CORS)
|
||||
const TOKEN_PROXY_URL = `${API_BASE}/api/sentinel/token`;
|
||||
|
||||
// browser-storage keys
|
||||
const LS_CLIENT_ID = 'sb_sentinel_client_id';
|
||||
const LS_CLIENT_SECRET = 'sb_sentinel_client_secret';
|
||||
|
||||
// In-memory token cache (never persisted)
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
// Dedup: only one in-flight token request at a time
|
||||
let _tokenPromise: Promise<string | null> | null = null;
|
||||
|
||||
// In-memory cache of "does the backend have Sentinel credentials configured?"
|
||||
// so the rest of the UI can short-circuit tile load attempts without a server
|
||||
// round-trip per tile. Refreshed by callers via `refreshSentinelStatus()`.
|
||||
let _backendCredentialsConfigured: boolean | null = null;
|
||||
let _backendStatusPromise: Promise<boolean> | null = null;
|
||||
// ─── Credential helpers ────────────────────────────────────────────────────
|
||||
|
||||
// ─── Credential status (server-side) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ask the backend whether Sentinel credentials are configured in ``.env``.
|
||||
* Caches the result in memory; call ``refreshSentinelStatus()`` after the
|
||||
* operator saves new API keys in the settings panel.
|
||||
*
|
||||
* Returns ``false`` on network errors so the UI fails safely (no broken
|
||||
* tile requests). Never returns the secret itself — that stays server-side.
|
||||
*/
|
||||
export async function checkBackendSentinelStatus(): Promise<boolean> {
|
||||
if (_backendCredentialsConfigured !== null) return _backendCredentialsConfigured;
|
||||
if (_backendStatusPromise) return _backendStatusPromise;
|
||||
|
||||
_backendStatusPromise = (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/settings/api-keys`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
const list = await resp.json();
|
||||
// /api/settings/api-keys returns an array of { id, env_key, is_set, ... }
|
||||
const ids = new Set(['sentinel_client_id', 'sentinel_client_secret']);
|
||||
const configured = Array.isArray(list)
|
||||
&& list.filter((row: { id?: string; is_set?: boolean }) =>
|
||||
row && row.id && ids.has(row.id) && row.is_set === true,
|
||||
).length === 2;
|
||||
_backendCredentialsConfigured = configured;
|
||||
return configured;
|
||||
} catch {
|
||||
_backendCredentialsConfigured = false;
|
||||
return false;
|
||||
} finally {
|
||||
_backendStatusPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return _backendStatusPromise;
|
||||
export function getSentinelCredentials(): {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
} {
|
||||
if (typeof window === 'undefined') return { clientId: '', clientSecret: '' };
|
||||
return {
|
||||
clientId: getSensitiveBrowserItem(LS_CLIENT_ID) || '',
|
||||
clientSecret: getSensitiveBrowserItem(LS_CLIENT_SECRET) || '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Invalidate the cached status — call this after the API Keys panel saves. */
|
||||
export function refreshSentinelStatus(): void {
|
||||
_backendCredentialsConfigured = null;
|
||||
// Drop any cached token too — credentials may have changed.
|
||||
export function setSentinelCredentials(clientId: string, clientSecret: string): void {
|
||||
setSensitiveBrowserItem(LS_CLIENT_ID, clientId);
|
||||
setSensitiveBrowserItem(LS_CLIENT_SECRET, clientSecret);
|
||||
// Invalidate cached token when credentials change
|
||||
cachedToken = null;
|
||||
tokenExpiry = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous getter — returns the last known status without a network call.
|
||||
* Returns ``null`` until ``checkBackendSentinelStatus()`` has run at least once.
|
||||
*/
|
||||
export function getCachedSentinelStatus(): boolean | null {
|
||||
return _backendCredentialsConfigured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat shim. Pre-#298 callers asked ``hasSentinelCredentials()`` to
|
||||
* decide whether to render the Sentinel layer / open the API key prompt.
|
||||
* The credential now lives server-side, so this is just the cached
|
||||
* server-status check. Returns ``false`` until the first
|
||||
* ``checkBackendSentinelStatus()`` resolves (callers should kick that off
|
||||
* once at app startup — see ``page.tsx`` mount effect).
|
||||
*/
|
||||
export function hasSentinelCredentials(): boolean {
|
||||
return _backendCredentialsConfigured === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration helper: clear the legacy browser-storage keys that
|
||||
* pre-#298 versions used to persist Sentinel credentials. Idempotent and
|
||||
* safe to call on every page load — does nothing if no keys are present.
|
||||
*
|
||||
* Called by ``SettingsPanel`` on mount. We do NOT auto-POST the legacy
|
||||
* browser values to the backend, because doing so would silently migrate
|
||||
* a secret across a trust boundary without operator consent. Operators
|
||||
* who relied on browser-stored credentials will re-enter them once in
|
||||
* the API Keys panel, and the legacy keys get wiped here.
|
||||
*/
|
||||
export function migrateLegacySentinelBrowserKeys(): { cleared: string[] } {
|
||||
if (typeof window === 'undefined') return { cleared: [] };
|
||||
const legacy = [
|
||||
'sb_sentinel_client_id',
|
||||
'sb_sentinel_client_secret',
|
||||
'sb_sentinel_instance_id',
|
||||
];
|
||||
const cleared: string[] = [];
|
||||
for (const key of legacy) {
|
||||
try {
|
||||
if (window.localStorage?.getItem(key) !== null) {
|
||||
window.localStorage.removeItem(key);
|
||||
cleared.push(key);
|
||||
}
|
||||
} catch { /* ignore quota / privacy mode errors */ }
|
||||
try {
|
||||
if (window.sessionStorage?.getItem(key) !== null) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
if (!cleared.includes(key)) cleared.push(key);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
export function clearSentinelCredentials(): void {
|
||||
removeSensitiveBrowserItem(LS_CLIENT_ID);
|
||||
removeSensitiveBrowserItem(LS_CLIENT_SECRET);
|
||||
// Also remove legacy instance ID if present
|
||||
removeSensitiveBrowserItem('sb_sentinel_instance_id');
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('sb_sentinel_instance_id');
|
||||
sessionStorage.removeItem('sb_sentinel_instance_id');
|
||||
}
|
||||
return { cleared };
|
||||
cachedToken = null;
|
||||
tokenExpiry = 0;
|
||||
}
|
||||
|
||||
export function getSentinelCredentialStorageMode(): 'local' | 'session' {
|
||||
return getSensitiveBrowserStorageMode();
|
||||
}
|
||||
|
||||
export function hasSentinelCredentials(): boolean {
|
||||
const { clientId, clientSecret } = getSentinelCredentials();
|
||||
return Boolean(clientId && clientSecret);
|
||||
}
|
||||
|
||||
// ─── OAuth2 token ──────────────────────────────────────────────────────────
|
||||
@@ -139,16 +79,14 @@ export function migrateLegacySentinelBrowserKeys(): { cleared: string[] } {
|
||||
/**
|
||||
* Fetch an OAuth2 access token using the client_credentials grant.
|
||||
* Caches in memory; auto-refreshes 30 s before expiry.
|
||||
*
|
||||
* The request body NO LONGER carries client_id/secret — the backend
|
||||
* resolves credentials from its ``.env`` via the API Keys flow. The
|
||||
* server-side proxy still accepts body credentials for legacy callers,
|
||||
* but the dashboard does not supply them.
|
||||
*/
|
||||
export function getSentinelToken(): Promise<string | null> {
|
||||
// Return cached token if still valid (with 30 s margin)
|
||||
if (cachedToken && Date.now() < tokenExpiry - 30_000) return Promise.resolve(cachedToken);
|
||||
|
||||
const { clientId, clientSecret } = getSentinelCredentials();
|
||||
if (!clientId || !clientSecret) return Promise.resolve(null);
|
||||
|
||||
// Dedup: reuse in-flight request so 20 tiles don't each trigger a token fetch
|
||||
if (_tokenPromise) return _tokenPromise;
|
||||
|
||||
@@ -156,9 +94,11 @@ export function getSentinelToken(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch(TOKEN_PROXY_URL, {
|
||||
method: 'POST',
|
||||
// Backend resolves credentials from env. Empty body = "use server-side".
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({}),
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -191,8 +131,6 @@ const TILE_PROXY_URL = `${API_BASE}/api/sentinel/tile`;
|
||||
/**
|
||||
* Fetch a single 256×256 tile via backend proxy to Sentinel Hub Process API.
|
||||
* Returns a PNG ArrayBuffer or null on failure.
|
||||
*
|
||||
* Body no longer carries client_id/secret — the backend uses .env values.
|
||||
*/
|
||||
export async function fetchSentinelTile(
|
||||
z: number,
|
||||
@@ -201,10 +139,21 @@ export async function fetchSentinelTile(
|
||||
preset: string,
|
||||
date: string,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
const { clientId, clientSecret } = getSentinelCredentials();
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
const resp = await fetch(TILE_PROXY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preset, date, z, x, y }),
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
preset,
|
||||
date,
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
|
||||
Reference in New Issue
Block a user