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
+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(