Files
alro65 cfd94f905a 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>
2026-07-03 12:45:43 -04:00

451 lines
22 KiB
Python

"""
REST API for S-57 ENC chart management.
"""
import asyncio
import io
import logging
import shutil
import tempfile
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse
from services.chart_manager import (
install_from_zip, install_from_enc, list_cells,
delete_cell, get_all_features, get_all_depths,
get_all_land, get_all_hazards, get_all_zones,
CHARTS_DIR, set_meta, get_region,
install_from_csv_zip, install_from_csv_dir,
)
router = APIRouter(prefix="/charts", tags=["charts"])
log = logging.getLogger(__name__)
# Florida/Miami NOAA ENC cells — name → description
NOAA_FLORIDA_CELLS = {
"US5FL17M": "Miami Harbor",
"US5FL16M": "Biscayne Bay",
"US4FL15M": "Florida Keys — East",
"US4FL16M": "Florida Keys — West",
"US5FL18M": "Port Everglades (Fort Lauderdale)",
"US5FL19M": "Lake Worth / Palm Beach",
"US4FL14M": "Florida Bay",
"US5FL13M": "Card Sound / Barnes Sound",
"US4FL13M": "Straits of Florida",
"US5FL12M": "Hawk Channel East",
"US3FL04M": "Florida Straits Overview",
}
NOAA_BASE = "https://charts.noaa.gov/ENCs/{cell}.zip"
# Catálogo local NOAA: cell_id → (west, south, east, north, scale, description)
# Fuente: NOAA Office of Coast Survey ENC catalog
NOAA_CATALOG: list[dict] = [
# ── FLORIDA / MIAMI ───────────────────────────────────────────────────────
{"id":"US5FL17M","name":"Miami Harbor", "scale":10000, "w":-80.20,"s":25.73,"e":-80.10,"n":25.82},
{"id":"US5FL16M","name":"Biscayne Bay", "scale":40000, "w":-80.32,"s":25.42,"e":-80.08,"n":25.78},
{"id":"US5FL18M","name":"Port Everglades", "scale":10000, "w":-80.15,"s":26.05,"e":-80.06,"n":26.13},
{"id":"US5FL19M","name":"Lake Worth — Palm Beach", "scale":10000, "w":-80.10,"s":26.67,"e":-80.01,"n":26.76},
{"id":"US4FL15M","name":"Florida Keys — East", "scale":80000, "w":-80.60,"s":24.40,"e":-79.80,"n":25.50},
{"id":"US4FL16M","name":"Florida Keys — West", "scale":80000, "w":-82.00,"s":24.40,"e":-80.60,"n":25.20},
{"id":"US4FL14M","name":"Florida Bay", "scale":80000, "w":-81.50,"s":24.80,"e":-80.30,"n":25.70},
{"id":"US5FL13M","name":"Card Sound / Barnes Sound", "scale":20000, "w":-80.50,"s":25.10,"e":-80.25,"n":25.45},
{"id":"US4FL13M","name":"Straits of Florida", "scale":150000, "w":-81.00,"s":24.00,"e":-79.50,"n":26.00},
{"id":"US3FL04M","name":"Florida Straits Overview", "scale":600000, "w":-85.00,"s":22.00,"e":-78.00,"n":28.00},
{"id":"US5FL20M","name":"Miami Beach / Government Cut", "scale":5000, "w":-80.14,"s":25.75,"e":-80.10,"n":25.78},
{"id":"US5FL21M","name":"Fort Lauderdale — New River", "scale":10000, "w":-80.20,"s":26.07,"e":-80.09,"n":26.15},
{"id":"US5FL14M","name":"Key West Harbor", "scale":15000, "w":-81.86,"s":24.52,"e":-81.72,"n":24.60},
{"id":"US5FL15M","name":"Key West Approaches", "scale":40000, "w":-82.20,"s":24.30,"e":-81.50,"n":24.80},
{"id":"US4FL17M","name":"Tampa Bay", "scale":80000, "w":-82.80,"s":27.40,"e":-82.20,"n":28.10},
{"id":"US5FL01M","name":"Tampa Bay — Port Tampa", "scale":15000, "w":-82.60,"s":27.83,"e":-82.40,"n":27.95},
{"id":"US5FL04M","name":"Jacksonville", "scale":15000, "w":-81.70,"s":30.27,"e":-81.55,"n":30.42},
# ── NEW YORK / NEW JERSEY ────────────────────────────────────────────────
{"id":"US5NY01M","name":"New York Harbor", "scale":10000, "w":-74.15,"s":40.57,"e":-73.95,"n":40.72},
{"id":"US5NY02M","name":"Upper New York Bay", "scale":10000, "w":-74.10,"s":40.63,"e":-73.95,"n":40.75},
{"id":"US5NY13M","name":"New York — Lower Bay", "scale":20000, "w":-74.20,"s":40.48,"e":-73.90,"n":40.68},
{"id":"US4NY10M","name":"New York Approaches", "scale":80000, "w":-74.50,"s":40.20,"e":-73.50,"n":41.00},
{"id":"US5NY05M","name":"East River", "scale":10000, "w":-74.02,"s":40.65,"e":-73.90,"n":40.80},
{"id":"US5NY06M","name":"Hudson River — New York", "scale":10000, "w":-74.05,"s":40.68,"e":-73.95,"n":40.85},
{"id":"US5NJ01M","name":"Port Newark / Elizabeth", "scale":10000, "w":-74.18,"s":40.62,"e":-74.08,"n":40.72},
{"id":"US5NJ02M","name":"Newark Bay", "scale":10000, "w":-74.18,"s":40.64,"e":-74.05,"n":40.73},
{"id":"US3NY01M","name":"New York — Long Island Sound Overview","scale":400000,"w":-74.50,"s":40.00,"e":-71.50,"n":41.50},
# ── CHESAPEAKE / MID-ATLANTIC ────────────────────────────────────────────
{"id":"US5MD01M","name":"Baltimore Harbor", "scale":10000, "w":-76.65,"s":39.20,"e":-76.50,"n":39.32},
{"id":"US4MD01M","name":"Chesapeake Bay — Northern", "scale":80000, "w":-76.70,"s":38.80,"e":-75.80,"n":39.70},
{"id":"US4VA01M","name":"Chesapeake Bay — Southern", "scale":80000, "w":-76.70,"s":36.80,"e":-75.80,"n":38.00},
{"id":"US5VA01M","name":"Norfolk — Hampton Roads", "scale":15000, "w":-76.40,"s":36.82,"e":-76.20,"n":37.00},
{"id":"US5PA01M","name":"Philadelphia", "scale":10000, "w":-75.20,"s":39.88,"e":-75.06,"n":39.98},
# ── NEW ENGLAND ──────────────────────────────────────────────────────────
{"id":"US5MA01M","name":"Boston Harbor", "scale":10000, "w":-71.10,"s":42.28,"e":-70.95,"n":42.40},
{"id":"US4MA01M","name":"Cape Cod — Approaches", "scale":80000, "w":-71.20,"s":41.40,"e":-69.80,"n":42.50},
{"id":"US5RI01M","name":"Providence — Narragansett Bay","scale":15000, "w":-71.48,"s":41.60,"e":-71.35,"n":41.85},
# ── GULF COAST ───────────────────────────────────────────────────────────
{"id":"US5LA01M","name":"New Orleans — Lower Mississippi","scale":10000,"w":-89.85,"s":29.85,"e":-89.70,"n":30.05},
{"id":"US5TX01M","name":"Houston Ship Channel", "scale":10000, "w":-95.10,"s":29.70,"e":-94.90,"n":29.85},
{"id":"US5TX02M","name":"Galveston Bay", "scale":10000, "w":-94.95,"s":29.25,"e":-94.65,"n":29.60},
{"id":"US4TX01M","name":"Texas — Gulf Coast", "scale":80000, "w":-97.50,"s":25.50,"e":-93.50,"n":30.00},
{"id":"US5MS01M","name":"Pascagoula", "scale":10000, "w":-88.65,"s":30.28,"e":-88.48,"n":30.38},
{"id":"US5AL01M","name":"Mobile Bay", "scale":20000, "w":-88.15,"s":30.50,"e":-87.90,"n":30.75},
# ── WEST COAST ───────────────────────────────────────────────────────────
{"id":"US5CA01M","name":"Los Angeles — Long Beach", "scale":15000, "w":-118.30,"s":33.70,"e":-118.10,"n":33.82},
{"id":"US5CA02M","name":"San Diego Bay", "scale":10000, "w":-117.22,"s":32.60,"e":-117.10,"n":32.75},
{"id":"US5CA03M","name":"San Francisco Bay", "scale":20000, "w":-122.55,"s":37.60,"e":-122.15,"n":37.90},
{"id":"US5CA04M","name":"San Francisco — Oakland", "scale":10000, "w":-122.42,"s":37.75,"e":-122.22,"n":37.86},
{"id":"US5OR01M","name":"Columbia River — Astoria", "scale":15000, "w":-124.00,"s":46.10,"e":-123.75,"n":46.30},
{"id":"US5WA01M","name":"Seattle — Elliott Bay", "scale":10000, "w":-122.45,"s":47.55,"e":-122.30,"n":47.66},
{"id":"US5WA02M","name":"Puget Sound — Tacoma", "scale":15000, "w":-122.55,"s":47.15,"e":-122.35,"n":47.35},
{"id":"US4WA01M","name":"Puget Sound Overview", "scale":80000, "w":-123.30,"s":46.80,"e":-122.00,"n":48.60},
# ── ALASKA ───────────────────────────────────────────────────────────────
{"id":"US5AK01M","name":"Anchorage", "scale":15000, "w":-150.10,"s":61.15,"e":-149.75,"n":61.32},
{"id":"US4AK01M","name":"Cook Inlet — Overview", "scale":80000, "w":-153.00,"s":59.00,"e":-148.00,"n":63.00},
# ── GREAT LAKES ──────────────────────────────────────────────────────────
{"id":"US5MI01M","name":"Detroit River", "scale":10000, "w":-83.15,"s":42.22,"e":-82.95,"n":42.40},
{"id":"US5IL01M","name":"Chicago Harbor", "scale":10000, "w":-87.68,"s":41.82,"e":-87.55,"n":41.92},
{"id":"US5OH01M","name":"Cleveland Harbor", "scale":10000, "w":-81.75,"s":41.48,"e":-81.60,"n":41.56},
# ── CARIBBEAN / PUERTO RICO ──────────────────────────────────────────────
{"id":"US5PR01M","name":"San Juan Harbor", "scale":10000, "w":-66.15,"s":18.43,"e":-66.05,"n":18.50},
{"id":"US4PR01M","name":"Puerto Rico — North Coast", "scale":80000, "w":-67.50,"s":17.80,"e":-65.00,"n":18.80},
{"id":"US5VI01M","name":"Charlotte Amalie — St. Thomas","scale":10000, "w":-64.97,"s":18.30,"e":-64.88,"n":18.37},
# ── HAWAII ───────────────────────────────────────────────────────────────
{"id":"US5HI01M","name":"Honolulu Harbor", "scale":10000, "w":-157.90,"s":21.27,"e":-157.83,"n":21.34},
{"id":"US4HI01M","name":"Oahu — Overview", "scale":80000, "w":-158.50,"s":21.00,"e":-157.50,"n":21.80},
]
@router.get("/cells")
def get_cells():
return list_cells()
@router.get("/florida-catalog")
def florida_catalog():
installed = {c["id"] for c in list_cells()}
return [
{"id": k, "description": v, "installed": k in installed}
for k, v in NOAA_FLORIDA_CELLS.items()
]
@router.get("/noaa-catalog")
def noaa_catalog():
installed = {c["id"] for c in list_cells()}
return [
{"id": c["id"], "description": c["name"], "scale": c["scale"], "installed": c["id"] in installed}
for c in NOAA_CATALOG
]
@router.get("/noaa-nearby")
def noaa_nearby(west: float, south: float, east: float, north: float):
"""
Return NOAA ENC cells that intersect the given bbox.
Checks both the hardcoded catalog AND installed cells' actual bounding boxes.
"""
cells_info = list_cells()
installed = {c["id"] for c in cells_info}
# Build actual-bbox map for installed cells
actual_bbox = {c["id"]: c["bbox"] for c in cells_info if c["bbox"]}
seen = set()
matches = []
# 1. Installed cells with real bounding boxes
for cell_id, bbox in actual_bbox.items():
w, s, e, n = bbox
if e < west or w > east or n < south or s > north:
continue
# Look up catalog name if available
name = next((c["name"] for c in NOAA_CATALOG if c["id"] == cell_id), cell_id)
scale = next((c["scale"] for c in NOAA_CATALOG if c["id"] == cell_id), 0)
matches.append({"id": cell_id, "name": name, "scale": scale, "installed": True})
seen.add(cell_id)
# 2. Catalog cells not yet installed
for cell in NOAA_CATALOG:
if cell["id"] in seen:
continue
if cell["e"] < west or cell["w"] > east:
continue
if cell["n"] < south or cell["s"] > north:
continue
matches.append({
"id": cell["id"],
"name": cell["name"],
"scale": cell["scale"],
"installed": cell["id"] in installed,
})
matches.sort(key=lambda c: c["scale"])
return matches
import re as _re
_CELL_RE = _re.compile(r'^US[1-5][A-Z]{2}\d{2}M$')
@router.post("/download-noaa/{cell_id}")
async def download_noaa(cell_id: str):
cell_id = cell_id.upper()
if not _CELL_RE.match(cell_id):
raise HTTPException(400, f"Invalid cell_id format: {cell_id}")
url = NOAA_BASE.format(cell=cell_id)
log.info("Downloading NOAA ENC %s from %s", cell_id, url)
try:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.get(url, follow_redirects=True)
if r.status_code == 404:
raise HTTPException(404, f"Cell {cell_id} not found on NOAA server")
if r.status_code != 200:
raise HTTPException(502, f"NOAA returned {r.status_code}")
except httpx.TimeoutException:
raise HTTPException(504, "Timeout downloading from NOAA")
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(r.content)
tmp_path = Path(tmp.name)
try:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
def _zip_contains_csvs(zip_path: Path) -> bool:
"""Return True if the ZIP has *.csv files but no *.000 ENC files."""
import zipfile as _zf
with _zf.ZipFile(zip_path) as z:
names = z.namelist()
has_csv = any(n.lower().endswith(".csv") for n in names)
has_enc = any(n.upper().endswith(".000") for n in names)
return has_csv and not has_enc
@router.post("/upload")
async def upload_chart(file: UploadFile = File(...)):
"""
Universal chart upload.
Accepts:
• .000 — single S-57 ENC cell
• .zip — either a NOAA ENC zip (contains .000) OR a CSV-based custom
chart zip (contains *.csv, no .000). The ZIP auto-detection
determines which parser is used.
"""
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".zip", ".000"):
raise HTTPException(400, "Only .zip or .000 files accepted")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
if suffix == ".zip":
# Auto-detect: CSV zip vs ENC zip
if _zip_contains_csvs(tmp_path):
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_zip, tmp_path)
else:
orig_name = Path(file.filename).stem.upper() if file.filename else None
cell_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_enc, tmp_path, orig_name)
installed = [cell_id]
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
@router.post("/upload-csv")
async def upload_csv_chart(file: UploadFile = File(...),
cell_id: str | None = None):
"""
Upload a ZIP archive containing CSV navigation-aid files to create a
custom chart cell. Use this when your source data is in DIMAR / custom
CSV format rather than S-57 .000.
The cell_id query parameter overrides the inferred name from the ZIP.
Workflow:
1. Edit BOYLAT.csv, BOYCAR.csv, BOYSPEC.csv, etc. in your local
capas_ctg/ folder.
2. Zip the entire folder.
3. POST the zip here (optionally with ?cell_id=BAHIA_DE_CARTAGENA).
4. AidsMonitoring reads the CSVs directly, preserving all light
attributes (LITCHR, SIGPER, VALNMR …) without GDAL round-trip loss.
"""
if not (file.filename or "").lower().endswith(".zip"):
raise HTTPException(400, "Only .zip files accepted for CSV upload")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
if cell_id:
# Extract to temp dir then install with explicit cell_id
import zipfile as _zf
import tempfile as _tf
with _tf.TemporaryDirectory() as td:
td_p = Path(td)
with _zf.ZipFile(tmp_path) as z:
for member in z.namelist():
if member.lower().endswith(".csv"):
data_bytes = z.read(member)
(td_p / Path(member).name).write_bytes(data_bytes)
installed_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_dir, td_p, cell_id)
installed = [installed_id]
else:
installed = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_zip, tmp_path)
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"installed": installed}
@router.delete("/cells/{cell_id}")
def remove_cell(cell_id: str):
delete_cell(cell_id)
return {"deleted": cell_id.upper()}
@router.patch("/cells/{cell_id}/region")
def set_cell_region(cell_id: str, region: str):
region = (region or "").upper().strip()
if region not in ("A", "B"):
raise HTTPException(400, "region must be 'A' or 'B'")
try:
set_meta(cell_id, region=region)
except FileNotFoundError:
raise HTTPException(404, f"Cell {cell_id} not installed")
return {"id": cell_id.upper(), "region": region}
# Chart caches are rebuilt at server startup / explicit /rebuild-cache.
# Between rebuilds, GeoJSON content is stable, so we let the browser cache
# the response for an hour. ETag/304 would be safer but max-age is enough
# for typical operator sessions and avoids redownloading 350 MB of depths.
_CHART_CACHE_HEADERS = {"Cache-Control": "private, max-age=3600"}
def _bbox(w, s, e, n):
return (w, s, e, n) if None not in (w, s, e, n) else None
@router.get("/features")
def chart_features(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_features(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/depths")
def chart_depths(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_depths(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/land")
def chart_land(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_land(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/hazards")
def chart_hazards(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_hazards(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/zones")
def chart_zones(w: float | None = None, s: float | None = None,
e: float | None = None, n: float | None = None):
return JSONResponse(get_all_zones(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.post("/rebuild-cache")
async def rebuild_cache():
"""Re-parse ALL installed ENC files and regenerate feature cache."""
from services.chart_manager import CHARTS_DIR, _build_cache
import asyncio
rebuilt = []
for cell_dir in CHARTS_DIR.iterdir():
if not cell_dir.is_dir():
continue
enc_files = list(cell_dir.glob("*.000"))
if not enc_files:
continue
await asyncio.get_event_loop().run_in_executor(
None, _build_cache, cell_dir.name, enc_files[0])
rebuilt.append(cell_dir.name)
return {"rebuilt": rebuilt}
@router.post("/cells/{cell_id}/rebuild-from-csv")
async def rebuild_cell_from_csv(cell_id: str, file: UploadFile = File(...)):
"""
Update an existing cell's features.geojson by re-uploading its CSV zip.
Equivalent to DELETE + upload-csv but preserves meta.json settings
(e.g. region override).
"""
if not (file.filename or "").lower().endswith(".zip"):
raise HTTPException(400, "Only .zip files accepted")
data = await file.read()
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
import zipfile as _zf, tempfile as _tf
with _tf.TemporaryDirectory() as td:
td_p = Path(td)
with _zf.ZipFile(tmp_path) as z:
for member in z.namelist():
if member.lower().endswith(".csv"):
(td_p / Path(member).name).write_bytes(z.read(member))
installed_id = await asyncio.get_event_loop().run_in_executor(
None, install_from_csv_dir, td_p, cell_id)
except Exception as e:
raise HTTPException(500, str(e))
finally:
tmp_path.unlink(missing_ok=True)
return {"rebuilt": installed_id}
@router.post("/cells/{cell_id}/rebuild")
async def rebuild_cell(cell_id: str):
"""Re-parse a single ENC cell and regenerate its feature cache."""
from services.chart_manager import CHARTS_DIR, _build_cache
import asyncio
cell_dir = CHARTS_DIR / cell_id
if not cell_dir.is_dir():
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found")
enc_files = list(cell_dir.glob("*.000"))
if not enc_files:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"No .000 file in cell '{cell_id}'")
await asyncio.get_event_loop().run_in_executor(
None, _build_cache, cell_id, enc_files[0])
return {"rebuilt": cell_id}