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:
Binary file not shown.
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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user