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:
2026-07-03 12:45:43 -04:00
parent 3e04c4113f
commit cfd94f905a
47 changed files with 1847 additions and 427 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+101 -2
View File
@@ -62,10 +62,37 @@ class AidUpdate(BaseModel):
radio_borneo_m: Optional[float] = None
observaciones: Optional[str] = None
lamp_id: Optional[str] = None
# AIS link — operator-editable. Once set, AIS Type 21 with this MMSI
# will update this aid's lat_actual and trigger drift/battery alerts.
mmsi: Optional[str] = None
tipo_ais: Optional[str] = None
# Per-aid alarm thresholds (override global config when set)
displacement_warn_m: Optional[float] = None
displacement_alarm_m: Optional[float] = None
signal_loss_min: Optional[int] = None
din3_function: Optional[str] = None
din4_function: Optional[str] = None
# Chart link (set when aid was promoted from S-57 feature; usually
# untouched after creation)
source_chart: Optional[str] = None
cell_id: Optional[str] = None
chart_feature_id: Optional[str] = None
motivo_cambio: str
modificado_por: str
class AidFromChartFeature(BaseModel):
"""Payload to create an Aid record anchored to a specific S-57 chart feature."""
cell_id: str
chart_feature_id: str
lat_nominal: float
lon_nominal: float
nombre: str
tipo: str = "BOYA_ESPECIAL" # caller can refine from S-57 type
categoria: str = "FLOTANTE"
radio_borneo_m: float = 10.0
class LampAssign(BaseModel):
lamp_id: Optional[str] = None # None → unassign
@@ -76,6 +103,25 @@ def list_aids(db: Session = Depends(get_db)):
return [_aid_dict(a, _lamp_for(a, db)) for a in aids]
# Static paths declared BEFORE /{aid_id} so FastAPI doesn't capture
# "by-chart-feature" as an aid_id.
@router.get("/by-chart-feature")
def aid_by_chart_feature(
cell_id: str = Query(...),
feature_id: str = Query(...),
db: Session = Depends(get_db),
):
"""Return the Aid record linked to a specific S-57 chart feature, or 404."""
aid = db.query(Aid).filter(
Aid.cell_id == cell_id,
Aid.chart_feature_id == feature_id,
Aid.activa == True,
).first()
if not aid:
raise HTTPException(404, "No aid linked to this chart feature")
return _aid_dict(aid, _lamp_for(aid, db))
@router.get("/{aid_id}")
def get_aid(aid_id: str, db: Session = Depends(get_db)):
aid = db.query(Aid).filter(Aid.id == aid_id).first()
@@ -111,9 +157,23 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db),
aid = db.query(Aid).filter(Aid.id == aid_id).first()
if not aid:
raise HTTPException(status_code=404, detail="Ayuda no encontrada")
# Empty-string MMSI → unassign. Reject if MMSI is already used by another aid.
if data.mmsi is not None:
mmsi_clean = data.mmsi.strip() or None
if mmsi_clean:
taken = db.query(Aid).filter(
Aid.mmsi == mmsi_clean, Aid.id != aid_id
).first()
if taken:
raise HTTPException(409,
f"MMSI {mmsi_clean} ya está asignado a '{taken.nombre}'")
aid.mmsi = mmsi_clean
for field in ["lat_nominal", "lon_nominal", "puerto_responsable",
"empresa_responsable", "caracteristica_luz", "alcance_nm",
"radio_borneo_m", "observaciones", "lamp_id"]:
"radio_borneo_m", "observaciones", "lamp_id",
"tipo_ais", "displacement_warn_m", "displacement_alarm_m",
"signal_loss_min", "din3_function", "din4_function",
"source_chart", "cell_id", "chart_feature_id"]:
val = getattr(data, field)
if val is not None:
setattr(aid, field, val)
@@ -122,7 +182,46 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db),
aid.modificado_en = datetime.utcnow()
db.commit()
db.refresh(aid)
return aid
return _aid_dict(aid, _lamp_for(aid, db))
@router.post("/from-chart-feature")
def create_aid_from_chart_feature(
data: AidFromChartFeature,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Promote a S-57 chart feature into a monitored Aid record.
Idempotent: if an aid already exists for (cell_id, chart_feature_id),
returns it instead of creating a duplicate."""
existing = db.query(Aid).filter(
Aid.cell_id == data.cell_id,
Aid.chart_feature_id == data.chart_feature_id,
Aid.activa == True,
).first()
if existing:
return _aid_dict(existing, _lamp_for(existing, db))
aid = Aid(
id=str(uuid.uuid4()),
nombre=data.nombre,
categoria=data.categoria,
tipo=data.tipo,
tipo_ais="SIN_AIS", # operator can change later
lat_nominal=data.lat_nominal,
lon_nominal=data.lon_nominal,
fuente_posicion="S57",
radio_borneo_m=data.radio_borneo_m,
source_chart="S57",
cell_id=data.cell_id,
chart_feature_id=data.chart_feature_id,
modificado_por=current_user.nombre if current_user else None,
motivo_cambio="Promoted from S-57 chart feature",
)
db.add(aid)
db.commit()
db.refresh(aid)
return _aid_dict(aid, _lamp_for(aid, db))
@router.get("/recordings", tags=["recordings"])
def list_recordings(
+30 -10
View File
@@ -181,9 +181,14 @@ def noaa_nearby(west: float, south: float, east: float, north: float):
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:
@@ -333,35 +338,50 @@ def set_cell_region(cell_id: str, region: str):
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():
return JSONResponse(get_all_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):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_depths(bbox))
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):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_land(bbox))
return JSONResponse(get_all_land(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.get("/hazards")
def chart_hazards():
return JSONResponse(get_all_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):
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
return JSONResponse(get_all_zones(bbox))
return JSONResponse(get_all_zones(_bbox(w, s, e, n)),
headers=_CHART_CACHE_HEADERS)
@router.post("/rebuild-cache")
+10 -4
View File
@@ -22,19 +22,23 @@ class LampIn(BaseModel):
lamp_count: int = 1
voltage_min: float = Field(..., gt=0)
voltage_max: float = Field(..., gt=0)
warn_pct: float = Field(20.0, ge=1, le=50) # % of range → warning
alarm_pct: float = Field(10.0, ge=1, le=50) # % of range → alarm
notes: Optional[str] = None
def compute_thresholds(vmin: float, vmax: float) -> dict:
def compute_thresholds(vmin: float, vmax: float,
warn_pct: float = 20.0, alarm_pct: float = 10.0) -> dict:
rng = vmax - vmin
return {
"warn_v": round(vmin + rng * 0.20, 3),
"alarm_v": round(vmin + rng * 0.10, 3),
"warn_v": round(vmin + rng * (warn_pct / 100), 3),
"alarm_v": round(vmin + rng * (alarm_pct / 100), 3),
}
def _lamp_dict(l: Lamp) -> dict:
th = compute_thresholds(l.voltage_min, l.voltage_max)
th = compute_thresholds(l.voltage_min, l.voltage_max,
l.warn_pct or 20.0, l.alarm_pct or 10.0)
return {
"id": l.id,
"manufacturer": l.manufacturer,
@@ -42,6 +46,8 @@ def _lamp_dict(l: Lamp) -> dict:
"lamp_count": l.lamp_count,
"voltage_min": l.voltage_min,
"voltage_max": l.voltage_max,
"warn_pct": l.warn_pct or 20.0,
"alarm_pct": l.alarm_pct or 10.0,
"notes": l.notes,
"warn_v": th["warn_v"],
"alarm_v": th["alarm_v"],