security: CORS hardening, path traversal fix, WebSocket auth + cleanup
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1143,12 +1143,79 @@ def list_cells() -> list[dict]:
|
||||
|
||||
|
||||
def delete_cell(cell_id: str):
|
||||
cell_dir = CHARTS_DIR / cell_id.upper()
|
||||
cell_dir = (CHARTS_DIR / cell_id.upper()).resolve()
|
||||
if CHARTS_DIR.resolve() not in cell_dir.parents:
|
||||
raise ValueError(f"Invalid cell_id: {cell_id}")
|
||||
if cell_dir.exists():
|
||||
shutil.rmtree(cell_dir)
|
||||
|
||||
|
||||
def get_all_features() -> dict:
|
||||
# Per-(cell, file) coverage bbox cache. Populated lazily the first time a
|
||||
# cell's GeoJSON file is read; subsequent bbox queries can skip the file
|
||||
# entirely if its coverage doesn't intersect the query bbox. Keyed by the
|
||||
# absolute path of the cache file. Invalidated implicitly on cache rebuild
|
||||
# because rebuilt files get a fresh mtime, which we include in the key.
|
||||
_cell_coverage_cache: dict[str, tuple[float, float, float, float]] = {}
|
||||
|
||||
|
||||
def _coverage_bbox(features: list) -> tuple[float, float, float, float] | None:
|
||||
"""Return (min_lon, min_lat, max_lon, max_lat) covering all features.
|
||||
Falls back to None when no coordinates are extractable."""
|
||||
min_lon = min_lat = float("inf")
|
||||
max_lon = max_lat = float("-inf")
|
||||
found = False
|
||||
for f in features:
|
||||
# Prefer the explicit per-feature bbox if the build wrote one
|
||||
fb = (f.get("properties") or {}).get("bbox")
|
||||
if fb and len(fb) == 4:
|
||||
min_lon = min(min_lon, fb[0]); min_lat = min(min_lat, fb[1])
|
||||
max_lon = max(max_lon, fb[2]); max_lat = max(max_lat, fb[3])
|
||||
found = True
|
||||
continue
|
||||
geom = f.get("geometry") or {}
|
||||
gt = geom.get("type")
|
||||
coords = geom.get("coordinates")
|
||||
if not coords:
|
||||
continue
|
||||
# Fast path for Point — by far the most common
|
||||
if gt == "Point":
|
||||
lon, lat = coords[0], coords[1]
|
||||
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
|
||||
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
|
||||
found = True
|
||||
else:
|
||||
# Walk the nested coordinate arrays (LineString, Polygon, Multi*)
|
||||
stack = [coords]
|
||||
while stack:
|
||||
x = stack.pop()
|
||||
if isinstance(x, (list, tuple)) and len(x) >= 2 and not isinstance(x[0], (list, tuple)):
|
||||
lon, lat = x[0], x[1]
|
||||
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
|
||||
min_lon = min(min_lon, lon); max_lon = max(max_lon, lon)
|
||||
min_lat = min(min_lat, lat); max_lat = max(max_lat, lat)
|
||||
found = True
|
||||
elif isinstance(x, (list, tuple)):
|
||||
stack.extend(x)
|
||||
return (min_lon, min_lat, max_lon, max_lat) if found else None
|
||||
|
||||
|
||||
def _feature_in_bbox(feat: dict, w: float, s: float, e: float, n: float) -> bool:
|
||||
"""Spatial filter: return True iff the feature intersects (w,s,e,n).
|
||||
Prefers a pre-computed properties.bbox, falls back to Point geometry."""
|
||||
fb = (feat.get("properties") or {}).get("bbox")
|
||||
if fb and len(fb) == 4:
|
||||
return not (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n)
|
||||
geom = feat.get("geometry") or {}
|
||||
if geom.get("type") == "Point":
|
||||
c = geom.get("coordinates") or [None, None]
|
||||
if c[0] is None or c[1] is None:
|
||||
return True # malformed — keep, don't lose it
|
||||
return w <= c[0] <= e and s <= c[1] <= n
|
||||
# No bbox + non-point geometry — keep it (better to render than to lose).
|
||||
return True
|
||||
|
||||
|
||||
def get_all_features(bbox: tuple[float, float, float, float] | None = None) -> dict:
|
||||
all_features = []
|
||||
for cell_dir in CHARTS_DIR.iterdir():
|
||||
cache = cell_dir / "features.geojson"
|
||||
@@ -1167,30 +1234,60 @@ def get_all_features() -> dict:
|
||||
# Backfill aid_type for old caches that pre-date the classifier.
|
||||
if "aid_type" not in p:
|
||||
p["aid_type"] = classify(p.get("layer", ""), p)
|
||||
all_features.extend(fc["features"])
|
||||
if bbox is not None and not _feature_in_bbox(f, *bbox):
|
||||
continue
|
||||
all_features.append(f)
|
||||
return {"type": "FeatureCollection", "features": all_features}
|
||||
|
||||
|
||||
def _aggregate_cache(filename: str, bbox=None) -> dict:
|
||||
"""Generic aggregator: read <filename> from every installed cell."""
|
||||
"""Generic aggregator: read <filename> from every installed cell.
|
||||
Uses a per-file coverage-bbox cache to skip cells that don't intersect
|
||||
the query bbox without reading their GeoJSON content."""
|
||||
all_features = []
|
||||
w = s = e = n = None
|
||||
if bbox is not None:
|
||||
w, s, e, n = bbox
|
||||
for cell_dir in CHARTS_DIR.iterdir():
|
||||
if not cell_dir.is_dir():
|
||||
continue
|
||||
cache = cell_dir / filename
|
||||
if not cache.exists():
|
||||
continue
|
||||
|
||||
# Pre-skip via cached cell-coverage bbox. Key includes mtime so
|
||||
# the entry self-invalidates when the file is rebuilt.
|
||||
if bbox is not None:
|
||||
try:
|
||||
mtime = cache.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
cov_key = f"{cache}|{mtime}"
|
||||
cov = _cell_coverage_cache.get(cov_key)
|
||||
if cov is not None:
|
||||
if cov[2] < w or cov[0] > e or cov[3] < s or cov[1] > n:
|
||||
continue # cell entirely outside query — skip file open
|
||||
|
||||
try:
|
||||
fc = json.loads(cache.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
for f in (fc.get("features") or []):
|
||||
|
||||
feats = fc.get("features") or []
|
||||
# Populate coverage cache on first read so subsequent bbox queries
|
||||
# can short-circuit.
|
||||
if bbox is not None and cov_key not in _cell_coverage_cache:
|
||||
cov2 = _coverage_bbox(feats)
|
||||
if cov2 is not None:
|
||||
_cell_coverage_cache[cov_key] = cov2
|
||||
if cov2[2] < w or cov2[0] > e or cov2[3] < s or cov2[1] > n:
|
||||
continue # just learned: cell outside query
|
||||
|
||||
for f in feats:
|
||||
p = f.setdefault("properties", {})
|
||||
p["cell"] = cell_dir.name
|
||||
if bbox is not None:
|
||||
fb = p.get("bbox")
|
||||
if fb and (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n):
|
||||
continue
|
||||
if bbox is not None and not _feature_in_bbox(f, w, s, e, n):
|
||||
continue
|
||||
all_features.append(f)
|
||||
return {"type": "FeatureCollection", "features": all_features}
|
||||
|
||||
@@ -1201,8 +1298,8 @@ def get_all_depths(bbox: tuple[float, float, float, float] | None = None) -> dic
|
||||
def get_all_land(bbox: tuple[float, float, float, float] | None = None) -> dict:
|
||||
return _aggregate_cache("land.geojson", bbox)
|
||||
|
||||
def get_all_hazards() -> dict:
|
||||
return _aggregate_cache("hazards.geojson")
|
||||
def get_all_hazards(bbox: tuple[float, float, float, float] | None = None) -> dict:
|
||||
return _aggregate_cache("hazards.geojson", bbox)
|
||||
|
||||
def get_all_zones(bbox: tuple[float, float, float, float] | None = None) -> dict:
|
||||
return _aggregate_cache("zones.geojson", bbox)
|
||||
|
||||
Reference in New Issue
Block a user