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
+81 -2
View File
@@ -171,14 +171,31 @@ const Modal = {
btn.textContent = 'SAVING...';
status.textContent = '';
// MMSI: empty string → unassign (sends "" so backend nulls the column).
// Non-empty → must be digits-only; backend enforces uniqueness across aids.
const mmsiVal = v('ef-mmsi');
if (mmsiVal && !/^\d{6,9}$/.test(mmsiVal)) {
status.textContent = 'MMSI must be 69 digits, or blank to unassign.';
status.className = 'save-status err';
btn.disabled = false; btn.textContent = 'SAVE CHANGES';
return;
}
const payload = {
puerto_responsable: v('ef-puerto') || null,
empresa_responsable: v('ef-empresa') || null,
caracteristica_luz: v('ef-luz') || null,
alcance_nm: flt('ef-alcance'),
radio_borneo_m: flt('ef-borneo'),
observaciones: v('ef-obs') || null,
lamp_id: v('ef-lamp') || null,
displacement_warn_m: flt('ef-drift-warn'),
displacement_alarm_m: flt('ef-drift-alarm'),
signal_loss_min: flt('ef-signal-loss') ? parseInt(v('ef-signal-loss')) : null,
din3_function: v('ef-din3') || null,
din4_function: v('ef-din4') || null,
observaciones: v('ef-obs') || null,
lamp_id: v('ef-lamp') || null,
mmsi: mmsiVal, // "" unassigns; backend handles
tipo_ais: mmsiVal ? 'ATON_21' : 'SIN_AIS',
modificado_por: Auth.session.nombre,
motivo_cambio: motivo,
};
@@ -273,6 +290,24 @@ function buildEditForm(p) {
</div>
</div>` : '';
// AIS link — when set, AIS Type 21 with this MMSI updates the buoy's
// lat_actual (ghost marker) and triggers drift/battery alerts using this
// aid's per-buoy thresholds.
const aisBlock = `
<div class="modal-section-label">AIS LINK</div>
<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:6px">
MMSI of the AtoN transponder on this buoy.
Once set, the system listens for AIS Type 21 with this MMSI and shows
the AIS-reported position as a transparent ghost over the nominal marker.
Leave blank if the buoy has no AIS.
</div>
<div class="form-field">
<label class="form-label">MMSI</label>
<input class="form-input" id="ef-mmsi" type="text" inputmode="numeric"
pattern="\\d{6,9}" maxlength="9"
value="${p.mmsi || ''}" placeholder="e.g. 993001002">
</div>`;
return `
<div class="modal-section-label">GENERAL</div>
@@ -327,12 +362,56 @@ function buildEditForm(p) {
value="${p.radio_borneo_m ?? 10}">
</div>
<div class="modal-section-label">ALERT THRESHOLDS</div>
<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:6px">
Leave blank to use global settings. Override per buoy based on anchor chain length and operating area.
</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Drift warn (m)</label>
<input class="form-input" id="ef-drift-warn" type="number" step="1" min="1"
value="${p.displacement_warn_m ?? ''}" placeholder="global">
</div>
<div class="form-field">
<label class="form-label">Drift alarm (m)</label>
<input class="form-input" id="ef-drift-alarm" type="number" step="1" min="1"
value="${p.displacement_alarm_m ?? ''}" placeholder="global">
</div>
<div class="form-field">
<label class="form-label">Signal loss (min)</label>
<input class="form-input" id="ef-signal-loss" type="number" step="1" min="1"
value="${p.signal_loss_min ?? ''}" placeholder="off">
</div>
</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Digital IN3 function</label>
<select class="form-input-select" id="ef-din3">
<option value="">— Not connected —</option>
<option value="WATER_INGRESS_WARN" ${p.din3_function==='WATER_INGRESS_WARN' ?'selected':''}>Water ingress (warning)</option>
<option value="WATER_INGRESS_CRITICAL" ${p.din3_function==='WATER_INGRESS_CRITICAL' ?'selected':''}>Water ingress (critical)</option>
<option value="LISTING" ${p.din3_function==='LISTING' ?'selected':''}>Listing / tilt sensor</option>
</select>
</div>
<div class="form-field">
<label class="form-label">Digital IN4 function</label>
<select class="form-input-select" id="ef-din4">
<option value="">— Not connected —</option>
<option value="WATER_INGRESS_WARN" ${p.din4_function==='WATER_INGRESS_WARN' ?'selected':''}>Water ingress (warning)</option>
<option value="WATER_INGRESS_CRITICAL" ${p.din4_function==='WATER_INGRESS_CRITICAL' ?'selected':''}>Water ingress (critical)</option>
<option value="LISTING" ${p.din4_function==='LISTING' ?'selected':''}>Listing / tilt sensor</option>
</select>
</div>
</div>
<div class="form-field">
<label class="form-label">Observations</label>
<textarea class="form-textarea" id="ef-obs"
style="height:64px">${p.observaciones || ''}</textarea>
</div>
${aisBlock}
${nominalBlock}
<div class="modal-section-label">AUDIT</div>
+810 -62
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -793,9 +793,11 @@ document.getElementById('btn-rec-search')?.addEventListener('click', async () =>
// ── MODAL LAMP CATALOG ────────────────────────────────────────────────────
window._lampCache = []; // exposed so the right panel can populate its dropdown
function _lampThresholds(vmin, vmax) {
function _lampThresholds(vmin, vmax, warn_pct, alarm_pct) {
const rng = vmax - vmin;
return { warn: +(vmin + rng * 0.20).toFixed(3), alarm: +(vmin + rng * 0.10).toFixed(3) };
const wp = ((warn_pct ?? 20) / 100);
const ap = ((alarm_pct ?? 10) / 100);
return { warn: +(vmin + rng * wp).toFixed(3), alarm: +(vmin + rng * ap).toFixed(3) };
}
async function loadLamps() {
@@ -844,20 +846,24 @@ window.editLamp = function(id) {
<td><input class="form-input" id="lp-edit-count" type="number" value="${l.lamp_count}" style="width:60px"></td>
<td><input class="form-input" id="lp-edit-vmin" type="number" step="0.1" value="${l.voltage_min}" style="width:70px"></td>
<td><input class="form-input" id="lp-edit-vmax" type="number" step="0.1" value="${l.voltage_max}" style="width:70px"></td>
<td colspan="2" style="font-size:0.7rem;color:var(--text-muted)" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
<td><input class="form-input" id="lp-edit-wpct" type="number" step="1" min="1" max="50" value="${l.warn_pct ?? 20}" style="width:55px" title="Warn %"></td>
<td><input class="form-input" id="lp-edit-apct" type="number" step="1" min="1" max="50" value="${l.alarm_pct ?? 10}" style="width:55px" title="Alarm %"></td>
<td style="font-size:0.7rem" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
<td><input class="form-input" id="lp-edit-notes" value="${l.notes || ''}"></td>
<td style="display:flex;gap:4px">
<button class="chart-row-btn" onclick="saveLamp('${id}')">SAVE</button>
<button class="chart-row-btn danger" onclick="renderLampsTable()">CANCEL</button>
</td>`;
// Live preview while editing vmin/vmax
['lp-edit-vmin','lp-edit-vmax'].forEach(i =>
['lp-edit-vmin','lp-edit-vmax','lp-edit-wpct','lp-edit-apct'].forEach(i =>
document.getElementById(i).addEventListener('input', () => {
const vmin = parseFloat(document.getElementById('lp-edit-vmin').value);
const vmax = parseFloat(document.getElementById('lp-edit-vmax').value);
const wpct = parseFloat(document.getElementById('lp-edit-wpct').value);
const apct = parseFloat(document.getElementById('lp-edit-apct').value);
const cell = document.getElementById('lp-edit-preview');
if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; }
const t = _lampThresholds(vmin, vmax);
const t = _lampThresholds(vmin, vmax, wpct, apct);
cell.innerHTML = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
}));
};
@@ -870,6 +876,8 @@ window.saveLamp = async function(id) {
lamp_count: parseInt(document.getElementById('lp-edit-count').value) || 1,
voltage_min: parseFloat(document.getElementById('lp-edit-vmin').value),
voltage_max: parseFloat(document.getElementById('lp-edit-vmax').value),
warn_pct: parseFloat(document.getElementById('lp-edit-wpct').value) || 20.0,
alarm_pct: parseFloat(document.getElementById('lp-edit-apct').value) || 10.0,
notes: document.getElementById('lp-edit-notes').value.trim() || null,
};
if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) {