Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
38eb4fe4d6 Merge pull request 'feat(map): quiet HUD chrome — VIIRS default, collapsed layer rail' (#49) from feat/hud-chrome-quiet into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 27s
Reviewed-on: #49
2026-09-01 17:21:09 -04:00
Sirius DevOps
0fa49b8407 fix(map): NEWS ticker fills 32px news-only dock
.ticker stayed at height:50% after MKT was hidden, leaving an empty
half-bar. .dock.news-only .ticker is now 100% of the 32px dock.
2026-09-01 17:15:32 -04:00
Sirius DevOps
beb457c382 feat(map): quiet HUD chrome — VIIRS default, collapsed rail
Satellite-first chrome: daily VIIRS preferred, layer rail collapsed on
load, geofence after basemap, FIRMS-only default overlays, hide MKT
standby ticker, Strait select instead of buttons, IBM Plex instead of
Orbitron, news as 6px circleMarkers.
2026-09-01 17:15:24 -04:00
cb20473119 Merge pull request 'feat(map): geofence watch HUD — finish/cancel, inbox, fence DVR' (#48) from feat/geofence-watch-ui into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 21s
Reviewed-on: #48
2026-09-01 07:44:36 -04:00
Sirius DevOps
8c97ce50d2 feat(map): geofence watch HUD — finish/cancel, inbox, fence DVR
Phone can finish a polygon without double-click. List is mute/go/rename/delete.
Inbox seeds from /api/geofence-alerts and appends WS hits. Watch all fence
ids on /ws/live. Selected-fence DVR uses GET /api/geofences/{id}/at.
2026-09-01 01:36:21 -04:00
75e065f8c2 Merge pull request 'feat(geofence): watch list, filtered hit log, fence snapshot' (#47) from feat/geofence-watch into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 15s
Reviewed-on: #47
2026-09-01 01:26:31 -04:00
Sirius DevOps
f6c1cfc454 feat(geofence): watch list, filtered hit log, fence snapshot
Off-viewport clients that send {type:watch_geofences,ids} on /ws/live
still receive geofence_alert; AIS/ADS-B stay viewport-only.

GET /api/geofence-alerts accepts geofence_id/since/until/source_kind.
GET /api/geofences/{id}/at returns CAGG+FIRMS inside the fence at T
(404 if missing, empty lists if DB down). DELETE missing fences 404s.
2026-09-01 01:24:36 -04:00
6ff2fd0351 Merge pull request 'fix(api): SSRF guard on ingest; whitelist PATCH /api/sources' (#46) from feat/ssrf-ingest-source-whitelist into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 22s
Reviewed-on: #46
2026-09-01 00:54:14 -04:00
622c548792 Merge branch 'master' into feat/ssrf-ingest-source-whitelist 2026-09-01 00:54:05 -04:00
5c6042c692 Merge pull request 'chore: remove unused masscan scanner' (#45) from feat/remove-masscan into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 21s
Reviewed-on: #45
2026-09-01 00:52:54 -04:00
e8060cf5d1 Merge pull request 'fix(ops): pin TiTiler digest, GIST bbox indexes, single uvicorn worker' (#44) from feat/pin-titiler-gist-single-worker into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 25s
Reviewed-on: #44
2026-09-01 00:51:40 -04:00
Sirius DevOps
59974be696 fix(api): SSRF guard on ingest; whitelist PATCH /api/sources
Reject private/loopback/link-local hosts on POST /api/ingest/rss and
URL-shaped GDELT queries via camera_scraper.is_public_url (HTTP 400).
PATCH /api/sources/{id} only accepts name, url, config, enabled (422 else).
2026-09-01 00:47:15 -04:00
Sirius DevOps
74722f7628 fix(ops): pin TiTiler digest, GIST bbox indexes, single uvicorn worker
Pin titiler to the Pi-running digest, make workers=1 explicit for
in-memory WS/layer caches, and add PostGIS GIST indexes for events/fires
bbox pans without dropping the existing btree indexes.
2026-09-01 00:47:06 -04:00
14 changed files with 1410 additions and 232 deletions

View file

@ -0,0 +1,29 @@
"""GIST bbox indexes for events/fires map-pan queries.
Revision ID: 010_bbox_gist
Revises: 009_vessels
Create Date: 2026-09-01
"""
from alembic import op
revision = "010_bbox_gist"
down_revision = "009_vessels"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_events_geom_gist ON events "
"USING gist (ST_SetSRID(ST_MakePoint(location_lon, location_lat), 4326))"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_fires_geom_gist ON fires "
"USING gist (ST_SetSRID(ST_MakePoint(longitude, latitude), 4326))"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_fires_geom_gist")
op.execute("DROP INDEX IF EXISTS ix_events_geom_gist")

View file

@ -0,0 +1,24 @@
"""geofence_alerts (geofence_id, created_at DESC) for fence-scoped hit log
Revision ID: 011_geofence_alerts_fence
Revises: 010_bbox_gist
Create Date: 2026-09-01
"""
from alembic import op
revision = "011_geofence_alerts_fence"
down_revision = "010_bbox_gist"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS ix_geofence_alerts_fence_created "
"ON geofence_alerts (geofence_id, created_at DESC)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_geofence_alerts_fence_created")

View file

@ -319,3 +319,154 @@ async def record_and_notify(
except Exception: except Exception:
pass pass
return sent return sent
async def list_alerts(
*,
geofence_id: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
source_kind: str | None = None,
limit: int = 100,
) -> list[dict]:
"""Filterable hit log. Empty list if the DB is down — never raises."""
where = ["TRUE"]
params: dict[str, Any] = {"limit": int(limit)}
if geofence_id:
where.append("geofence_id = CAST(:geofence_id AS uuid)")
params["geofence_id"] = geofence_id
if since is not None:
where.append("created_at >= :since")
params["since"] = since
if until is not None:
where.append("created_at <= :until")
params["until"] = until
if source_kind:
where.append("source_kind = :source_kind")
params["source_kind"] = source_kind
sql = f"""
SELECT id::text, geofence_id::text, source_kind, entity_id,
lat, lon, payload, created_at
FROM geofence_alerts
WHERE {' AND '.join(where)}
ORDER BY created_at DESC
LIMIT :limit
"""
try:
async with async_session() as session:
rows = (await session.execute(text(sql), params)).mappings().all()
out = []
for r in rows:
item = dict(r)
if item.get("created_at") is not None:
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
except Exception:
return []
async def get_geofence(gid: str) -> dict | None:
current = next((f for f in _cache if f["id"] == gid), None)
if current is not None:
return current
try:
await refresh_cache()
except Exception:
return None
return next((f for f in _cache if f["id"] == gid), None)
def _marker_from_track(row) -> dict:
from live_layers import to_marker
extra = {"bucket": row["bucket"].isoformat() if row.get("bucket") else None, "dvr": True}
return to_marker(
row["id"], row["lat"], row["lon"],
heading=row.get("heading"), speed=row.get("speed"),
label=row.get("label") or row["id"],
extra=extra,
)
async def _cagg_inside(gid: str, kind: str, bucket: datetime, limit: int = 2000) -> list[dict]:
table = "aircraft_tracks_1min" if kind == "aircraft" else "vessel_tracks_1min"
id_col = "hex" if kind == "aircraft" else "mmsi"
sql = f"""
SELECT {id_col} AS id, lat, lon, heading, speed, label, bucket
FROM {table}
WHERE bucket = :bucket
AND ST_Intersects(
(SELECT geom FROM geofences WHERE id = CAST(:gid AS uuid)),
ST_SetSRID(ST_MakePoint(lon, lat), 4326)
)
LIMIT :limit
"""
try:
async with async_session() as session:
rows = (await session.execute(
text(sql), {"bucket": bucket, "gid": gid, "limit": limit},
)).mappings().all()
return [
_marker_from_track(r)
for r in rows
if r["lat"] is not None and r["lon"] is not None
]
except Exception:
return []
async def _fires_inside(gid: str, ts: datetime, limit: int = 2000) -> list[dict]:
from tracks import minute_bucket
bucket = minute_bucket(ts)
t1 = bucket + timedelta(minutes=1)
sql = """
SELECT latitude, longitude, brightness, confidence, acq_time, satellite,
instrument, bright_ti5, frp, daynight
FROM fires
WHERE acq_time >= :t0 AND acq_time < :t1
AND ST_Intersects(
(SELECT geom FROM geofences WHERE id = CAST(:gid AS uuid)),
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
)
LIMIT :limit
"""
try:
async with async_session() as session:
rows = (await session.execute(
text(sql),
{"t0": bucket, "t1": t1, "gid": gid, "limit": limit},
)).mappings().all()
out = []
for r in rows:
item = dict(r)
if item.get("acq_time") is not None:
item["acq_time"] = item["acq_time"].isoformat()
out.append(item)
return out
except Exception:
return []
async def snapshot_at(gid: str, ts: datetime) -> dict | None:
"""Positions inside the fence at time T. None if the fence is missing.
Does not persist or notify. Empty lists if track/fire queries fail.
"""
fence = await get_geofence(gid)
if fence is None:
return None
from tracks import minute_bucket
bucket = minute_bucket(ts)
aircraft = await _cagg_inside(gid, "aircraft", bucket)
vessels = await _cagg_inside(gid, "vessel", bucket)
fires = await _fires_inside(gid, ts)
return {
"geofence_id": gid,
"timestamp": ts.isoformat(),
"aircraft": aircraft,
"vessels": vessels,
"fires": fires,
}

View file

@ -21,6 +21,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import NoReturn from typing import NoReturn
from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
import httpx import httpx
@ -43,7 +44,7 @@ from schemas import (
DashboardSummary, EntityCreate, EntityKind, EntityOut, DashboardSummary, EntityCreate, EntityKind, EntityOut,
EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut, EventCreate, EventOut, FireOut, NewsArticleOut, NewsMapItemOut,
NewsSummaryOut, NewsTickerItemOut, NewsSummaryOut, NewsTickerItemOut,
FeedSourceCreate, FeedSourceOut, FeedSourceCreate, FeedSourceOut, FeedSourceUpdate,
KeyOut, KeyValueIn, KeyOut, KeyValueIn,
NewsModelsOut, SettingsIn, SettingsOut, NewsModelsOut, SettingsIn, SettingsOut,
SearchResult, SentimentSummary, SourceType, SearchResult, SentimentSummary, SourceType,
@ -51,7 +52,8 @@ from schemas import (
GeofenceCreate, GeofenceUpdate, GeofenceCreate, GeofenceUpdate,
) )
from ingestor import ingest_event, fetch_and_process from ingestor import ingest_event, fetch_and_process
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals from camera_scraper import is_public_url
from sources import GDELT_API, ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_social_signals
from fire_sources import ingest_fires from fire_sources import ingest_fires
from keystore import KeyFormatError, delete_key, list_keys, set_key from keystore import KeyFormatError, delete_key, list_keys, set_key
from settings_store import SettingsError, get_app_settings, list_models, set_summary_model from settings_store import SettingsError, get_app_settings, list_models, set_summary_model
@ -366,18 +368,20 @@ async def create_source(payload: FeedSourceCreate):
@app.patch("/api/sources/{source_id}") @app.patch("/api/sources/{source_id}")
async def update_source(source_id: UUID, payload: dict): async def update_source(source_id: UUID, payload: FeedSourceUpdate):
"""Update a feed source (e.g., toggle enabled).""" """Update a feed source (name/url/config/enabled only)."""
values = payload.model_dump(exclude_unset=True)
async with async_session() as session: async with async_session() as session:
row = (await session.execute( row = (await session.execute(
select(feed_sources).where(feed_sources.c.id == source_id) select(feed_sources).where(feed_sources.c.id == source_id)
)).mappings().one_or_none() )).mappings().one_or_none()
if not row: if not row:
raise HTTPException(404, "Source not found") raise HTTPException(404, "Source not found")
if values:
await session.execute( await session.execute(
feed_sources.update() feed_sources.update()
.where(feed_sources.c.id == source_id) .where(feed_sources.c.id == source_id)
.values(**payload) .values(**values)
) )
await session.commit() await session.commit()
return {"ok": True} return {"ok": True}
@ -822,9 +826,15 @@ async def put_settings(payload: SettingsIn):
# ── Ingestion Triggers ─────────────────────────────────────────────────── # ── Ingestion Triggers ───────────────────────────────────────────────────
def _require_public_url(url: str, field: str) -> None:
if not is_public_url(url):
raise HTTPException(400, f"{field} is not a public URL")
@app.post("/api/ingest/rss") @app.post("/api/ingest/rss")
async def trigger_rss_ingest(feed_url: str, source_id: str | None = None): async def trigger_rss_ingest(feed_url: str, source_id: str | None = None):
"""Trigger RSS feed ingestion.""" """Trigger RSS feed ingestion."""
_require_public_url(feed_url, "feed_url")
count = await ingest_rss_feed(feed_url, source_id) count = await ingest_rss_feed(feed_url, source_id)
return {"status": "ok", "items_ingested": count} return {"status": "ok", "items_ingested": count}
@ -832,6 +842,10 @@ async def trigger_rss_ingest(feed_url: str, source_id: str | None = None):
@app.post("/api/ingest/gdelt") @app.post("/api/ingest/gdelt")
async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50): async def trigger_gdelt_ingest(query: str = "", max_articles: int = 50):
"""Trigger GDELT data ingestion.""" """Trigger GDELT data ingestion."""
_require_public_url(GDELT_API, "GDELT target")
parsed = urlparse(query)
if parsed.scheme in ("http", "https") and parsed.hostname:
_require_public_url(query, "query")
count = await ingest_gdelt(query, max_articles) count = await ingest_gdelt(query, max_articles)
return {"status": "ok", "articles_ingested": count} return {"status": "ok", "articles_ingested": count}
@ -859,7 +873,14 @@ async def trigger_social_ingest(query: str = "", max_items: int = 50):
@app.websocket("/ws/live") @app.websocket("/ws/live")
async def live_ws(ws: WebSocket): async def live_ws(ws: WebSocket):
"""Viewport-filtered AIS/ADS-B fan-out. Client sends {type:viewport,bbox}.""" """Viewport-filtered AIS/ADS-B fan-out.
Client JSON:
{"type":"viewport","bbox":"minlon,minlat,maxlon,maxlat"}
{"type":"watch_geofences","ids":["<uuid>", ...]} empty list = none
geofence_alert delivers if the point is in-viewport OR geofence_id is watched.
AIS/ADS-B/fire_aircraft stay viewport-only.
"""
from ws_manager import manager from ws_manager import manager
client_id = str(id(ws)) client_id = str(id(ws))
@ -885,6 +906,10 @@ async def live_ws(ws: WebSocket):
manager.set_viewport(client_id, parse_bbox(str(data["bbox"]))) manager.set_viewport(client_id, parse_bbox(str(data["bbox"])))
except ValueError: except ValueError:
continue continue
elif data.get("type") == "watch_geofences":
ids = data.get("ids") or []
if isinstance(ids, list):
manager.set_watched_geofences(client_id, [str(x) for x in ids])
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
@ -1743,33 +1768,68 @@ async def api_update_geofence(gid: str, payload: GeofenceUpdate):
@app.delete("/api/geofences/{gid}", status_code=204) @app.delete("/api/geofences/{gid}", status_code=204)
async def api_delete_geofence(gid: str): async def api_delete_geofence(gid: str):
from geofence import delete_geofence from geofence import delete_geofence
await delete_geofence(gid) ok = await delete_geofence(gid)
if not ok:
raise HTTPException(404, "geofence not found")
return None return None
@app.get("/api/geofence-alerts") @app.get("/api/geofences/{gid}/at")
async def api_geofence_alerts(limit: int = Query(100, ge=1, le=500)): async def api_geofence_at(
from sqlalchemy import text as sql_text gid: str,
timestamp: str = Query(..., description="ISO-8601 instant for the 1-minute DVR bucket"),
):
"""Aircraft/vessels/fires inside this fence at time T. Never writes."""
from geofence import snapshot_at
from tracks import parse_timestamp
try: try:
async with async_session() as session: ts = parse_timestamp(timestamp)
rows = (await session.execute(sql_text( except ValueError as exc:
""" raise HTTPException(422, str(exc)) from exc
SELECT id::text, geofence_id::text, source_kind, entity_id, if ts is None:
lat, lon, payload, created_at raise HTTPException(422, "timestamp required")
FROM geofence_alerts try:
ORDER BY created_at DESC body = await snapshot_at(gid, ts)
LIMIT :limit
"""
), {"limit": limit})).mappings().all()
out = []
for r in rows:
item = dict(r)
if item.get("created_at") is not None:
item["created_at"] = item["created_at"].isoformat()
out.append(item)
return out
except Exception: except Exception:
return [] body = {
"geofence_id": gid,
"timestamp": ts.isoformat(),
"aircraft": [],
"vessels": [],
"fires": [],
}
if body is None:
raise HTTPException(404, "geofence not found")
return body
@app.get("/api/geofence-alerts")
async def api_geofence_alerts(
geofence_id: UUID | None = Query(None),
since: str | None = Query(None, description="ISO-8601 inclusive lower bound"),
until: str | None = Query(None, description="ISO-8601 inclusive upper bound"),
source_kind: str | None = Query(None, description="firms|ais|adsb"),
limit: int = Query(100, ge=1, le=500),
):
"""Hit log for drawn fences. Not /api/alerts (entity/keyword)."""
from geofence import list_alerts
from tracks import parse_timestamp
if source_kind is not None and source_kind not in ("firms", "ais", "adsb"):
raise HTTPException(422, "source_kind must be one of: firms, ais, adsb")
try:
since_ts = parse_timestamp(since) if since else None
until_ts = parse_timestamp(until) if until else None
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
return await list_alerts(
geofence_id=str(geofence_id) if geofence_id else None,
since=since_ts,
until=until_ts,
source_kind=source_kind,
limit=limit,
)
@app.get("/api/fire-aircraft") @app.get("/api/fire-aircraft")
@ -1977,4 +2037,4 @@ app.mount("/static", CachedStaticFiles(directory=str(STATIC_DIR)), name="static"
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000, workers=1) # single worker: in-memory WS/pubsub + layer caches

View file

@ -7,7 +7,7 @@ from enum import Enum
from typing import Literal, Optional from typing import Literal, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
# ─── Enums ─────────────────────────────────────────────────────────────── # ─── Enums ───────────────────────────────────────────────────────────────
@ -63,6 +63,17 @@ class FeedSourceCreate(BaseModel):
config: Optional[dict] = None config: Optional[dict] = None
class FeedSourceUpdate(BaseModel):
"""PATCH /api/sources/{id} — only these keys may be set."""
model_config = ConfigDict(extra="forbid")
name: Optional[str] = None
url: Optional[str] = None
config: Optional[dict] = None
enabled: Optional[bool] = None
class FeedSourceOut(BaseModel): class FeedSourceOut(BaseModel):
id: UUID id: UUID
name: str name: str

File diff suppressed because it is too large Load diff

View file

@ -8,10 +8,18 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
from uuid import UUID
BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat
def _uuid_str(value: object) -> str | None:
try:
return str(UUID(str(value)))
except (ValueError, TypeError, AttributeError):
return None
def point_in_bbox(lon: float, lat: float, bbox: BBox | None) -> bool: def point_in_bbox(lon: float, lat: float, bbox: BBox | None) -> bool:
"""True if (lon, lat) sits inside an axis-aligned viewport.""" """True if (lon, lat) sits inside an axis-aligned viewport."""
if bbox is None: if bbox is None:
@ -26,6 +34,7 @@ class ConnectionManager:
def __init__(self) -> None: def __init__(self) -> None:
self._queues: dict[str, asyncio.Queue] = {} self._queues: dict[str, asyncio.Queue] = {}
self._viewports: dict[str, BBox] = {} self._viewports: dict[str, BBox] = {}
self._watched: dict[str, set[str]] = {}
def register(self, client_id: str, maxsize: int = 256) -> asyncio.Queue: def register(self, client_id: str, maxsize: int = 256) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=maxsize) q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)
@ -35,6 +44,21 @@ class ConnectionManager:
def unregister(self, client_id: str) -> None: def unregister(self, client_id: str) -> None:
self._queues.pop(client_id, None) self._queues.pop(client_id, None)
self._viewports.pop(client_id, None) self._viewports.pop(client_id, None)
self._watched.pop(client_id, None)
def set_watched_geofences(self, client_id: str, ids: list[str]) -> None:
"""Watch these fence UUIDs so geofence_alert delivers off-viewport.
Invalid UUIDs are ignored. Empty list = watch none (viewport-only).
"""
if client_id not in self._queues:
return
watched: set[str] = set()
for raw in ids:
uid = _uuid_str(raw)
if uid is not None:
watched.add(uid)
self._watched[client_id] = watched
def set_viewport(self, client_id: str, bbox: BBox) -> None: def set_viewport(self, client_id: str, bbox: BBox) -> None:
if client_id in self._queues: if client_id in self._queues:
@ -59,13 +83,21 @@ class ConnectionManager:
) -> int: ) -> int:
"""Enqueue `{type, payload}` for clients whose viewport contains the point. """Enqueue `{type, payload}` for clients whose viewport contains the point.
Drops the oldest queued message if a client's buffer is full so a slow kind=geofence_alert also delivers when payload.geofence_id is in the
tab cannot stall ingest. Returns the number of clients that got a copy. client's watch set (even if the point is off-viewport). Other kinds
stay viewport-only. Drops the oldest queued message if a client's
buffer is full. Returns the number of clients that got a copy.
""" """
msg = {"type": kind, "payload": payload} msg = {"type": kind, "payload": payload}
sent = 0 sent = 0
gid = _uuid_str(payload.get("geofence_id")) if kind == "geofence_alert" else None
for client_id, queue in list(self._queues.items()): for client_id, queue in list(self._queues.items()):
if not point_in_bbox(lon, lat, self._viewports.get(client_id)): in_view = point_in_bbox(lon, lat, self._viewports.get(client_id))
if kind == "geofence_alert":
watching = gid is not None and gid in self._watched.get(client_id, set())
if not in_view and not watching:
continue
elif not in_view:
continue continue
if queue.full(): if queue.full():
try: try:

View file

@ -170,7 +170,7 @@ services:
# Listens on 8000 INSIDE the container (the app already owns host 8000); # Listens on 8000 INSIDE the container (the app already owns host 8000);
# published on host loopback 127.0.0.1:8001 only. # published on host loopback 127.0.0.1:8001 only.
titiler: titiler:
image: ghcr.io/developmentseed/titiler:latest image: ghcr.io/developmentseed/titiler:latest@sha256:1809958d063543e3ec858259536002b2de78e9f8f09a22a8d9591bdc2b550b14
pull_policy: missing pull_policy: missing
container_name: osint-titiler container_name: osint-titiler
platform: linux/arm64 platform: linux/arm64

View file

@ -51,23 +51,33 @@ def test_matching_geofences_only_active_hits():
assert matching_geofences(-122.4, 37.7, fences) == [] assert matching_geofences(-122.4, 37.7, fences) == []
def test_geofence_alert_fans_out_only_to_viewport_clients(): FENCE_ID = "11111111-1111-1111-1111-111111111111"
mgr = ConnectionManager() NC_VIEW = (-80.0, 35.0, -78.0, 36.0)
q_nc = mgr.register("nc") SF_VIEW = (-123.0, 37.0, -121.0, 38.0)
q_sf = mgr.register("sf")
mgr.set_viewport("nc", (-80.0, 35.0, -78.0, 36.0))
mgr.set_viewport("sf", (-123.0, 37.0, -121.0, 38.0))
async def run():
payload = { def _alert_payload(gid=FENCE_ID):
"geofence_id": "a", return {
"geofence_id": gid,
"geofence_name": "NC", "geofence_name": "NC",
"source_kind": "ais", "source_kind": "ais",
"entity_id": "366123456", "entity_id": "366123456",
"lat": 35.5, "lat": 35.5,
"lon": -79.0, "lon": -79.0,
} }
n = await mgr.publish_point("geofence_alert", payload, lat=35.5, lon=-79.0)
def test_geofence_alert_fans_out_only_to_viewport_clients():
mgr = ConnectionManager()
q_nc = mgr.register("nc")
q_sf = mgr.register("sf")
mgr.set_viewport("nc", NC_VIEW)
mgr.set_viewport("sf", SF_VIEW)
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 1 assert n == 1
msg = q_nc.get_nowait() msg = q_nc.get_nowait()
assert msg["type"] == "geofence_alert" assert msg["type"] == "geofence_alert"
@ -77,6 +87,109 @@ def test_geofence_alert_fans_out_only_to_viewport_clients():
asyncio.run(run()) asyncio.run(run())
def test_off_viewport_watch_receives_geofence_alert():
mgr = ConnectionManager()
q_sf = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
mgr.set_watched_geofences("sf", [FENCE_ID])
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 1
msg = q_sf.get_nowait()
assert msg["type"] == "geofence_alert"
assert msg["payload"]["geofence_id"] == FENCE_ID
asyncio.run(run())
def test_off_viewport_without_watch_does_not_receive_geofence_alert():
mgr = ConnectionManager()
q_sf = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 0
assert q_sf.empty()
asyncio.run(run())
def test_on_viewport_receives_geofence_alert_without_watch():
mgr = ConnectionManager()
q_nc = mgr.register("nc")
mgr.set_viewport("nc", NC_VIEW)
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 1
assert q_nc.get_nowait()["type"] == "geofence_alert"
asyncio.run(run())
def test_ais_stays_viewport_only_even_when_watching():
mgr = ConnectionManager()
q_sf = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
mgr.set_watched_geofences("sf", [FENCE_ID])
async def run():
n = await mgr.publish_point("ais", {"id": "366123456"}, lat=35.5, lon=-79.0)
assert n == 0
assert q_sf.empty()
asyncio.run(run())
def test_invalid_watch_uuids_ignored_empty_list_clears():
mgr = ConnectionManager()
q = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
mgr.set_watched_geofences("sf", ["not-a-uuid", FENCE_ID, "also-bad"])
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 1
q.get_nowait()
mgr.set_watched_geofences("sf", [])
n2 = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n2 == 0
assert q.empty()
asyncio.run(run())
def test_unregister_clears_watched_geofences():
mgr = ConnectionManager()
q = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
mgr.set_watched_geofences("sf", [FENCE_ID])
mgr.unregister("sf")
q2 = mgr.register("sf")
mgr.set_viewport("sf", SF_VIEW)
async def run():
n = await mgr.publish_point(
"geofence_alert", _alert_payload(), lat=35.5, lon=-79.0,
)
assert n == 0
assert q2.empty()
asyncio.run(run())
def test_record_and_notify_queries_postgis_when_cache_empty(monkeypatch): def test_record_and_notify_queries_postgis_when_cache_empty(monkeypatch):
"""FIRMS ingest in the ingester has an empty in-process cache — still ST_Intersects.""" """FIRMS ingest in the ingester has an empty in-process cache — still ST_Intersects."""
import geofence import geofence
@ -134,3 +247,117 @@ def test_record_and_notify_queries_postgis_when_cache_empty(monkeypatch):
inserts = [p for p in executed if isinstance(p, dict)] inserts = [p for p in executed if isinstance(p, dict)]
assert inserts and inserts[0]["source_kind"] == "firms" assert inserts and inserts[0]["source_kind"] == "firms"
assert "commit" in executed assert "commit" in executed
def test_list_alerts_sql_filters(monkeypatch):
captured: dict = {}
class FakeResult:
def mappings(self):
return self
def all(self):
return []
class FakeSession:
async def execute(self, stmt, params=None):
captured["sql"] = str(stmt)
captured["params"] = params
return FakeResult()
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
monkeypatch.setattr(geofence, "async_session", FakeSession)
from datetime import datetime, timezone
since = datetime(2026, 8, 28, tzinfo=timezone.utc)
until = datetime(2026, 8, 29, tzinfo=timezone.utc)
async def run():
return await geofence.list_alerts(
geofence_id=FENCE_ID, since=since, until=until,
source_kind="firms", limit=5,
)
assert asyncio.run(run()) == []
sql = captured["sql"].lower()
assert "geofence_id" in sql
assert "created_at >=" in sql
assert "created_at <=" in sql
assert "source_kind" in sql
assert captured["params"]["geofence_id"] == FENCE_ID
assert captured["params"]["source_kind"] == "firms"
assert captured["params"]["limit"] == 5
def test_alembic_fence_created_index_exists():
from pathlib import Path
text = Path(__file__).resolve().parent.parent.joinpath(
"alembic/versions/011_geofence_alerts_fence.py",
).read_text()
assert "ix_geofence_alerts_fence_created" in text
assert "010_bbox_gist" in text
def test_snapshot_at_404_when_fence_missing(monkeypatch):
geofence._cache.clear()
async def boom():
raise RuntimeError("db down")
monkeypatch.setattr(geofence, "refresh_cache", boom)
async def run():
from datetime import datetime, timezone
return await geofence.snapshot_at(
FENCE_ID, datetime(2026, 8, 28, 12, 4, tzinfo=timezone.utc),
)
assert asyncio.run(run()) is None
def test_snapshot_queries_st_intersects(monkeypatch):
geofence._cache[:] = [{
"id": FENCE_ID, "name": "NC", "geojson": NC_BOX, "active": True,
}]
sqls: list[str] = []
class FakeResult:
def mappings(self):
return self
def all(self):
return []
class FakeSession:
async def execute(self, stmt, params=None):
sqls.append(str(stmt))
return FakeResult()
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
monkeypatch.setattr(geofence, "async_session", FakeSession)
async def run():
from datetime import datetime, timezone
return await geofence.snapshot_at(
FENCE_ID, datetime(2026, 8, 28, 12, 4, 30, tzinfo=timezone.utc),
)
body = asyncio.run(run())
assert body["aircraft"] == []
assert body["vessels"] == []
assert body["fires"] == []
blob = "\n".join(sqls).lower()
assert "st_intersects" in blob
assert "aircraft_tracks_1min" in blob
assert "vessel_tracks_1min" in blob
assert "from fires" in blob

View file

@ -1,4 +1,4 @@
"""Geofence layer panel: draw + delete (DELETE /api/geofences/{id}).""" """Geofence layer panel: draw, watch, inbox, delete (HTML contract)."""
from __future__ import annotations from __future__ import annotations
@ -24,3 +24,33 @@ def test_load_geofences_renders_delete_controls():
assert "deleteGeofence" in js assert "deleteGeofence" in js
assert "onEachFeature" in js assert "onEachFeature" in js
assert "bindPopup" in js assert "bindPopup" in js
def test_finish_cancel_draw_controls():
assert 'id="gf-finish"' in HTML
assert 'id="gf-cancel"' in HTML
assert "function cancelGeofenceDraw" in HTML
assert "function onGfClose" in HTML
def test_watch_geofences_ws_payload():
assert "watch_geofences" in HTML
assert "function sendWatchGeofences" in HTML
def test_geofence_alert_inbox():
assert 'id="gf-inbox"' in HTML
assert "/api/geofence-alerts" in HTML
assert "function loadGfInbox" in HTML
assert "function pushGfInbox" in HTML
def test_delete_geofence_still_present():
assert "function deleteGeofence" in HTML
assert "method: 'DELETE'" in HTML or 'method: "DELETE"' in HTML
def test_fence_dvr_at_endpoint():
assert "/at?timestamp=" in HTML or "/at?timestamp=${" in HTML
assert "function dvrScrubFence" in HTML
assert "gfSelectedId" in HTML

113
tests/test_hud_chrome.py Normal file
View file

@ -0,0 +1,113 @@
"""Quiet HUD chrome: VIIRS default, collapsed rail, no Orbitron/MKT dashes."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HTML = (ROOT / "app/static/index.html").read_text()
def _attr(html: str, elem_id: str) -> str:
chunk = html.split(f'id="{elem_id}"', 1)[1].split(">", 1)[0]
return chunk
def test_initmap_prefers_viirs_true_color():
init = HTML.split("async function initMap", 1)[1].split("function readMapPrefs", 1)[0]
assert "VIIRS_SNPP_CorrectedReflectance_TrueColor" in init
assert init.index("VIIRS_SNPP_CorrectedReflectance_TrueColor") < init.index(
"MODIS_Terra_CorrectedReflectance_TrueColor"
)
assert init.index("MODIS_Terra_CorrectedReflectance_TrueColor") < init.index(
"BlueMarble_ShadedRelief_Bathymetry"
)
def test_orbitron_gone():
assert "Orbitron" not in HTML
assert "IBM Plex Sans" in HTML
assert "IBM Plex Mono" in HTML
def test_lp_note_stripped_from_layer_list():
assert 'class="lp-note"' not in HTML
body = HTML.split('class="lp-body"', 1)[1].split("lp-legend", 1)[0]
assert "lp-note" not in body
def test_default_overlays_basemap_and_firms_only():
fires = _attr(HTML, "lp-fires-on")
assert "checked" in fires
for eid in (
"lp-cams-on",
"lp-blips-on",
"lp-news-on",
"lp-radar-on",
"lp-alerts-on",
"lp-perim-on",
"lp-ac-on",
"lp-trains-on",
"lp-storms-on",
):
assert "checked" not in _attr(HTML, eid), eid
def test_geofence_markup_before_cameras():
assert 'id="gf-draw"' in HTML
assert HTML.index('id="gf-draw"') < HTML.index('id="lp-cams-on"')
assert HTML.index('id="lp-base-on"') < HTML.index('id="gf-draw"')
def test_parent_geofence_hud_survives():
assert "watch_geofences" in HTML
assert "function deleteGeofence" in HTML
assert 'id="gf-finish"' in HTML
assert 'id="gf-cancel"' in HTML
assert 'id="gf-inbox"' in HTML
def test_layer_rail_collapsed_on_load():
head = HTML.split('class="lp-head"', 1)[1].split("</div>", 1)[0]
assert 'aria-expanded="false"' in head
assert 'id="layer-panel" class="collapsed"' in HTML
def test_market_ticker_hidden_no_poll():
mkt = HTML.split('class="ticker market"', 1)[1].split(">", 1)[0]
assert "hidden" in mkt
assert "setInterval(probeMarket" not in HTML
assert "setInterval(loadMarket" not in HTML
init = HTML.split("function initMarketTicker", 1)[1].split("function ", 1)[0]
assert "/api/market" in init or "404-poll" in init
assert "setInterval" not in init
def test_news_ticker_fills_news_only_dock():
css = HTML.split("</style>", 1)[0]
compact = css.replace(" ", "").replace("\n", "")
assert ".dock.news-only{height:32px;}" in compact
assert ".dock.news-only.ticker{height:100%;}" in compact
assert ".ticker{display:flex;align-items:stretch;height:50%;" in compact
def test_news_pins_are_circle_markers():
js = HTML.split("async function loadNewsPins", 1)[1].split("function refreshLiveOverlays", 1)[0]
assert "L.circleMarker" in js
assert "fillOpacity: 0.7" in js or "fillOpacity:0.7" in js
assert "rotate(45deg)" not in js
assert "L.divIcon" not in js
def test_chokepoint_buttons_not_in_toolbar_flow():
assert 'id="chokepoint-select"' in HTML
css = HTML.split("</style>", 1)[0]
assert ".chokepoint-btns { display: none; }" in css or ".chokepoint-btns{display:none" in css.replace(
" ", ""
)
def test_brand_is_osint_slash():
assert "GLOBAL SITUATIONAL AWARENESS TERMINAL" not in HTML
assert "OSINT" in HTML
assert 'class="accent">//</span>' in HTML

View file

@ -0,0 +1,67 @@
"""SSRF guard on ingest triggers + PATCH /api/sources allowlist."""
from __future__ import annotations
import asyncio
import httpx
import pytest
from pydantic import ValidationError
from main import app
BASE = "http://test"
LINK_LOCAL_META = "http://169.254.169.254/latest/meta-data/"
LOOPBACK = "http://127.0.0.1/secret"
async def _req(method: str, path: str, **kw) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
return await client.request(method, path, **kw)
def test_rss_ingest_rejects_link_local_metadata_url(monkeypatch):
called = {"n": 0}
async def _boom(*_a, **_k):
called["n"] += 1
raise AssertionError("ingest_rss_feed must not run for a private URL")
monkeypatch.setattr("main.ingest_rss_feed", _boom)
resp = asyncio.run(_req("POST", "/api/ingest/rss", params={"feed_url": LINK_LOCAL_META}))
assert resp.status_code == 400
assert called["n"] == 0
def test_gdelt_ingest_rejects_private_query_url(monkeypatch):
called = {"n": 0}
async def _boom(*_a, **_k):
called["n"] += 1
raise AssertionError("ingest_gdelt must not run for a private URL query")
monkeypatch.setattr("main.ingest_gdelt", _boom)
resp = asyncio.run(_req("POST", "/api/ingest/gdelt", params={"query": LOOPBACK}))
assert resp.status_code == 400
assert called["n"] == 0
def test_update_source_rejects_unknown_fields():
sid = "00000000-0000-0000-0000-000000000001"
resp = asyncio.run(_req("PATCH", f"/api/sources/{sid}", json={"enabled": True, "source_type": "rss"}))
assert resp.status_code == 422
def test_feed_source_update_allowlist_only():
from schemas import FeedSourceUpdate
payload = FeedSourceUpdate(name="n", url="https://example.com/rss", config={"k": 1}, enabled=False)
assert payload.model_dump(exclude_unset=True) == {
"name": "n",
"url": "https://example.com/rss",
"config": {"k": 1},
"enabled": False,
}
with pytest.raises(ValidationError):
FeedSourceUpdate.model_validate({"enabled": True, "id": "00000000-0000-0000-0000-000000000001"})

View file

@ -31,3 +31,35 @@ def test_no_redis_kafka_celery():
assert "kafka" not in blob assert "kafka" not in blob
assert "celery" not in blob assert "celery" not in blob
assert "cachetools" in req assert "cachetools" in req
def test_titiler_image_pinned_by_digest():
text = (ROOT / "docker-compose.yml").read_text()
assert (
"ghcr.io/developmentseed/titiler:latest@sha256:"
"1809958d063543e3ec858259536002b2de78e9f8f09a22a8d9591bdc2b550b14"
in text
)
# Unpinned :latest would drift on every pull.
for line in text.splitlines():
if "titiler" in line.lower() and "image:" in line:
assert "@sha256:" in line
def test_uvicorn_single_worker_guard():
text = (ROOT / "app" / "main.py").read_text()
main_block = text.split('if __name__ == "__main__":', 1)[1]
assert "workers=1" in main_block
def test_bbox_gist_migration_keeps_btree_and_adds_gist():
text = (ROOT / "alembic" / "versions" / "010_bbox_gist.py").read_text()
assert "down_revision" in text and "009_vessels" in text
assert "ix_events_geom_gist" in text
assert "ix_fires_geom_gist" in text
assert "ST_MakePoint(location_lon, location_lat)" in text
assert "ST_MakePoint(longitude, latitude)" in text
assert "USING gist" in text
models = (ROOT / "app" / "models.py").read_text()
assert 'Index("ix_events_location"' in models
assert 'Index("ix_fires_bbox"' in models

View file

@ -17,6 +17,9 @@ async def _req(method: str, path: str, **kw) -> httpx.Response:
return await client.request(method, path, **kw) return await client.request(method, path, **kw)
FENCE_ID = "11111111-1111-1111-1111-111111111111"
def test_geofence_post_rejects_point(): def test_geofence_post_rejects_point():
resp = asyncio.run(_req( resp = asyncio.run(_req(
"POST", "/api/geofences", "POST", "/api/geofences",
@ -25,6 +28,111 @@ def test_geofence_post_rejects_point():
assert resp.status_code == 422 assert resp.status_code == 422
def test_delete_geofence_404_when_missing(monkeypatch):
async def missing(_gid: str) -> bool:
return False
monkeypatch.setattr("geofence.delete_geofence", missing)
resp = asyncio.run(_req("DELETE", f"/api/geofences/{FENCE_ID}"))
assert resp.status_code == 404
def test_geofence_alerts_passes_filters(monkeypatch):
seen = {}
async def fake_list(**kwargs):
seen.update(kwargs)
return [{"id": "a", "geofence_id": FENCE_ID, "source_kind": "ais"}]
monkeypatch.setattr("geofence.list_alerts", fake_list)
resp = asyncio.run(_req(
"GET", "/api/geofence-alerts",
params={
"geofence_id": FENCE_ID,
"since": "2026-08-28T00:00:00Z",
"until": "2026-08-29T00:00:00Z",
"source_kind": "ais",
"limit": 10,
},
))
assert resp.status_code == 200
assert resp.json()[0]["source_kind"] == "ais"
assert seen["geofence_id"] == FENCE_ID
assert seen["source_kind"] == "ais"
assert seen["limit"] == 10
assert seen["since"] is not None
assert seen["until"] is not None
def test_geofence_alerts_rejects_bad_source_kind():
resp = asyncio.run(_req(
"GET", "/api/geofence-alerts", params={"source_kind": "camera"},
))
assert resp.status_code == 422
def test_geofence_at_404_when_missing(monkeypatch):
async def no_snap(gid: str, ts):
return None
monkeypatch.setattr("geofence.snapshot_at", no_snap)
resp = asyncio.run(_req(
"GET", f"/api/geofences/{FENCE_ID}/at",
params={"timestamp": "2026-08-28T12:04:00Z"},
))
assert resp.status_code == 404
def test_geofence_at_empty_lists_when_db_down(monkeypatch):
async def empty_snap(gid: str, ts):
return {
"geofence_id": gid,
"timestamp": ts.isoformat(),
"aircraft": [],
"vessels": [],
"fires": [],
}
monkeypatch.setattr("geofence.snapshot_at", empty_snap)
resp = asyncio.run(_req(
"GET", f"/api/geofences/{FENCE_ID}/at",
params={"timestamp": "2026-08-28T12:04:00Z"},
))
assert resp.status_code == 200
body = resp.json()
assert body["geofence_id"] == FENCE_ID
assert body["aircraft"] == []
assert body["vessels"] == []
assert body["fires"] == []
assert "timestamp" in body
def test_geofence_at_does_not_notify(monkeypatch):
called = {"notify": 0}
async def empty_snap(gid: str, ts):
return {
"geofence_id": gid,
"timestamp": ts.isoformat(),
"aircraft": [],
"vessels": [],
"fires": [],
}
async def boom(**_kw):
called["notify"] += 1
raise AssertionError("GET /at must not record_and_notify")
monkeypatch.setattr("geofence.snapshot_at", empty_snap)
monkeypatch.setattr("geofence.record_and_notify", boom)
resp = asyncio.run(_req(
"GET", f"/api/geofences/{FENCE_ID}/at",
params={"timestamp": "2026-08-28T12:04:00Z"},
))
assert resp.status_code == 200
assert called["notify"] == 0
def test_geofences_list_does_not_collide_with_alerts(): def test_geofences_list_does_not_collide_with_alerts():
resp = asyncio.run(_req("GET", "/api/geofences")) resp = asyncio.run(_req("GET", "/api/geofences"))
assert resp.status_code == 200 assert resp.status_code == 200