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

619 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const API = ''; // relative — works from any IP (localhost, Tailscale, LAN)
const SESSION_KEY = 'ams_session';
// ── Estado de sesión ────────────────────────────────────────────────────────
const Auth = {
session: null,
load() {
try {
const raw = localStorage.getItem(SESSION_KEY);
this.session = raw ? JSON.parse(raw) : null;
} catch { this.session = null; }
this._renderBadge();
},
save(data) {
this.session = data;
localStorage.setItem(SESSION_KEY, JSON.stringify(data));
this._renderBadge();
},
clear() {
this.session = null;
localStorage.removeItem(SESSION_KEY);
this._renderBadge();
},
isLoggedIn() { return !!this.session?.token; },
isAdmin() { return ['ADMIN','SUPERADMIN'].includes(this.session?.role); },
isSuperAdmin(){ return this.session?.role === 'SUPERADMIN'; },
token() { return this.session?.token || ''; },
_renderBadge() {
const el = document.getElementById('session-badge');
if (!el) return;
if (!this.session) {
el.innerHTML = `<span style="opacity:.5">NOT LOGGED IN</span>`;
el.title = 'Click to login';
el.onclick = () => Modal.openLogin(null);
} else {
const roleClass = {
SUPERADMIN: 'role-superadmin',
ADMIN: 'role-admin',
USER: 'role-user',
}[this.session.role] || 'role-user';
el.innerHTML = `
<span>${this.session.nombre}</span>
<span class="session-role ${roleClass}">${this.session.role}</span>
`;
el.title = 'Click to logout';
el.onclick = () => {
if (confirm(`Logout as ${this.session.nombre}?`)) {
Auth.clear();
window._clearPortConstraints?.();
_portCache = null;
// Return to login screen
document.getElementById('app').classList.add('hidden');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('ls-user').value = '';
document.getElementById('ls-pass').value = '';
document.getElementById('ls-error').classList.add('hidden');
setTimeout(() => document.getElementById('ls-user').focus(), 100);
}
};
}
},
};
// ── Modal controller ────────────────────────────────────────────────────────
const Modal = {
_pendingAid: null,
openLogin(aidData) {
this._pendingAid = aidData;
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
document.getElementById('login-error').classList.add('hidden');
_showOverlay('modal-login');
setTimeout(() => document.getElementById('login-user').focus(), 100);
},
closeLogin() {
_hideOverlay();
this._pendingAid = null;
},
async submitLogin() {
const username = document.getElementById('login-user').value.trim();
const password = document.getElementById('login-pass').value;
const errEl = document.getElementById('login-error');
const btn = document.getElementById('btn-login-submit');
if (!username || !password) return;
btn.disabled = true;
btn.textContent = 'AUTHENTICATING...';
errEl.classList.add('hidden');
try {
const form = new URLSearchParams({ username, password });
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
});
if (!res.ok) throw new Error('invalid');
const data = await res.json();
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
_hideOverlay();
afterLogin();
_fetchAndApplyPort(data.access_token, data.role); // update constraints if user changed
if (this._pendingAid) {
this.openEdit(this._pendingAid);
this._pendingAid = null;
}
} catch {
errEl.textContent = 'Invalid credentials or insufficient permissions.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'LOGIN';
}
},
async openEdit(aidData) {
// Si no tiene sesión con permisos → abrir login primero
if (!Auth.isAdmin()) {
this.openLogin(aidData);
return;
}
document.getElementById('edit-aid-name').textContent = aidData.nombre;
document.getElementById('edit-save-status').textContent = '';
document.getElementById('edit-save-status').className = 'save-status';
const body = document.getElementById('modal-edit-body');
body.innerHTML = '<div style="padding:20px;color:var(--text-muted)">Loading…</div>';
_showOverlay('modal-edit');
// Refresh caches so the dropdowns show up-to-date contacts and lamps
try {
await Promise.all([
window.loadContacts ? window.loadContacts() : Promise.resolve(),
window.loadLamps ? window.loadLamps() : Promise.resolve(),
]);
} catch {}
body.innerHTML = buildEditForm(aidData);
document.getElementById('btn-edit-submit').onclick = () => this.submitEdit(aidData);
},
closeEdit() { _hideOverlay(); },
async submitEdit(aidData) {
const btn = document.getElementById('btn-edit-submit');
const status = document.getElementById('edit-save-status');
const motivo = document.getElementById('ef-motivo')?.value?.trim();
if (!motivo) {
status.textContent = 'Reason for change is required.';
status.className = 'save-status err';
return;
}
btn.disabled = true;
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'),
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,
};
// Posición nominal solo si superadmin y se modificó
if (Auth.isSuperAdmin()) {
const lat = flt('ef-lat');
const lon = flt('ef-lon');
if (lat) payload.lat_nominal = lat;
if (lon) payload.lon_nominal = lon;
}
try {
const res = await fetch(`${API}/aids/${aidData.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Auth.token()}`,
},
body: JSON.stringify(payload),
});
if (res.status === 401) {
Auth.clear();
_hideOverlay();
this.openLogin(aidData);
return;
}
if (!res.ok) throw new Error(await res.text());
const updated = await res.json();
// Actualizar feature en el mapa
const feature = window.aidsSource?.getFeatureById(aidData.id);
if (feature) feature.setProperties({ ...feature.getProperties(), ...updated });
// Refrescar panel info
if (window.refreshInfoPanel) window.refreshInfoPanel({ ...aidData, ...updated });
status.textContent = 'Saved successfully.';
status.className = 'save-status ok';
setTimeout(() => _hideOverlay(), 1200);
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status err';
} finally {
btn.disabled = false;
btn.textContent = 'SAVE CHANGES';
}
},
};
// ── Form builder ─────────────────────────────────────────────────────────────
function buildEditForm(p) {
// Port options from PORT_AUTHORITY contacts (deduplicated by port_name)
const contacts = window._contactsCache || [];
const portsFromContacts = [...new Set(contacts
.filter(c => c.role === 'PORT_AUTHORITY' && c.port_name)
.map(c => c.port_name))];
// Keep the current value as an option even if no contact uses it yet
if (p.puerto_responsable && !portsFromContacts.includes(p.puerto_responsable)) {
portsFromContacts.push(p.puerto_responsable);
}
const pOpts = portsFromContacts.map(x =>
`<option value="${x}" ${p.puerto_responsable === x ? 'selected':''}>${x}</option>`).join('');
// Company options from OWNER contacts (deduplicated by company_name)
const companiesFromContacts = [...new Set(contacts
.filter(c => c.role === 'OWNER' && c.company_name)
.map(c => c.company_name))];
if (p.empresa_responsable && !companiesFromContacts.includes(p.empresa_responsable)) {
companiesFromContacts.push(p.empresa_responsable);
}
const cOpts = companiesFromContacts.map(x =>
`<option value="${x}" ${p.empresa_responsable === x ? 'selected':''}>${x}</option>`).join('');
// Lamp options from the lamp catalog
const lamps = window._lampCache || [];
const lOpts = lamps.map(l =>
`<option value="${l.id}" ${l.id === p.lamp_id ? 'selected':''}>${l.manufacturer} ${l.model} (${l.voltage_min}${l.voltage_max} V)</option>`).join('');
const nominalBlock = Auth.isSuperAdmin() ? `
<div class="modal-section-label">NOMINAL POSITION — SUPERADMIN ONLY</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Latitude</label>
<input class="form-input" id="ef-lat" type="number" step="0.00001" value="${p.lat_nominal || ''}">
</div>
<div class="form-field">
<label class="form-label">Longitude</label>
<input class="form-input" id="ef-lon" type="number" step="0.00001" value="${p.lon_nominal || ''}">
</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>
<div class="form-field">
<label class="form-label">Port Responsible
<span style="font-size:0.6rem;color:var(--text-muted)">(from contacts catalog)</span>
</label>
<select class="form-input-select" id="ef-puerto">
<option value="">-- Select --</option>
${pOpts}
</select>
</div>
<div class="form-field">
<label class="form-label">Company / Owner
<span style="font-size:0.6rem;color:var(--text-muted)">(from contacts catalog)</span>
</label>
<select class="form-input-select" id="ef-empresa">
<option value="">-- Select --</option>
${cOpts}
</select>
</div>
<div class="form-field">
<label class="form-label">Lamp Model
<span style="font-size:0.6rem;color:var(--text-muted)">(from lamp catalog — sets battery thresholds)</span>
</label>
<select class="form-input-select" id="ef-lamp">
<option value="">-- None --</option>
${lOpts}
</select>
</div>
<div class="modal-section-label">TECHNICAL</div>
<div class="field-row-modal">
<div class="form-field">
<label class="form-label">Light Characteristic</label>
<input class="form-input" id="ef-luz" type="text"
value="${p.caracteristica_luz || ''}" placeholder="e.g. Fl(2) R 10s">
</div>
<div class="form-field">
<label class="form-label">Range (nm)</label>
<input class="form-input" id="ef-alcance" type="number" step="0.5"
value="${p.alcance_nm || ''}">
</div>
</div>
<div class="form-field">
<label class="form-label">Swing Radius (m)</label>
<input class="form-input" id="ef-borneo" type="number" step="1"
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>
<div class="form-field">
<label class="form-label">Modified by</label>
<input class="form-input" value="${Auth.session?.nombre || ''}" disabled>
</div>
<div class="form-field">
<label class="form-label">Reason for change <span style="color:var(--red)">*</span></label>
<textarea class="form-textarea" id="ef-motivo"
style="height:56px" placeholder="Mandatory — describe the change..."></textarea>
</div>
`;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function v(id) { return document.getElementById(id)?.value?.trim() || ''; }
function flt(id) { const n = parseFloat(v(id)); return isNaN(n) ? null : n; }
function _showOverlay(modalId) {
document.getElementById('modal-overlay').classList.remove('hidden');
['modal-login','modal-edit'].forEach(id => {
document.getElementById(id).classList.toggle('hidden', id !== modalId);
});
}
function _hideOverlay() {
document.getElementById('modal-overlay').classList.add('hidden');
document.getElementById('modal-login').classList.add('hidden');
document.getElementById('modal-edit').classList.add('hidden');
}
// ── Port navigation + constraints ─────────────────────────────────────────────
// Cached port result — re-applied after the app container becomes visible.
let _portCache = null; // { lon, lat, zoom, port }
// Called right after #app becomes visible.
// OL initialized with display:none → size=0×0 → map renders half-screen and
// ignores setCenter. Fix: updateSize() tells OL the real container size,
// then re-apply port position so the map lands at the correct place.
function _showMap() {
requestAnimationFrame(() => {
window._mapUpdateSize?.(); // fix half-screen rendering
requestAnimationFrame(() => {
_applyPortToMap(); // re-apply position after resize
});
});
}
function _applyPortToMap() {
if (!_portCache) return;
const { lon, lat, zoom, port } = _portCache;
// Step 1: clear any previous constraints so navigation is free
window._clearPortConstraints?.();
// Step 2: fly to port (same as port search)
window.flyToCoords?.(lon, lat, zoom);
// Step 3: after animation lands, lock zoom-out and extent
if (port) {
setTimeout(() => window._setPortConstraints?.(port), 1400); // 1200ms anim + margin
}
}
// Fetch /org/me/company, cache result, apply to map.
// Call this BEFORE showing the app, then re-apply after show (OL resize fix).
async function _fetchAndApplyPort(token, role) {
try {
const res = await fetch(`${API}/org/me/company`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const me = await res.json();
if (!me.port || me.port.center_lat == null || me.port.center_lon == null) return;
const isClient = (role === 'USER' || role === 'CLIENT_ADMIN');
_portCache = {
lon: me.port.center_lon,
lat: me.port.center_lat,
zoom: me.port.default_zoom || 12,
port: isClient ? me.port : null,
};
_applyPortToMap();
} catch (_) { /* non-fatal */ }
}
// ── Login de inicio ───────────────────────────────────────────────────────────
async function initStartupLogin() {
const screen = document.getElementById('login-screen');
const app = document.getElementById('app');
const btn = document.getElementById('ls-submit');
const errEl = document.getElementById('ls-error');
// Validate cached token. If valid, configure map THEN show app.
Auth.load();
if (Auth.isLoggedIn()) {
try {
const r = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${Auth.token()}` },
});
if (r.ok) {
try {
await _fetchAndApplyPort(Auth.token(), Auth.session.role);
app.classList.remove('hidden');
window._mapUpdateSize?.();
_applyPortToMap();
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden');
}
screen.classList.add('hidden');
afterLogin();
return;
}
Auth.clear();
} catch { Auth.clear(); }
}
screen.classList.remove('hidden');
app.classList.add('hidden');
async function doLogin() {
const username = document.getElementById('ls-user').value.trim();
const password = document.getElementById('ls-pass').value;
if (!username || !password) return;
btn.disabled = true;
btn.textContent = 'AUTHENTICATING...';
errEl.classList.add('hidden');
try {
const form = new URLSearchParams({ username, password });
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form,
});
if (!res.ok) throw new Error('invalid');
const data = await res.json();
Auth.save({ token: data.access_token, username: data.username,
nombre: data.nombre, role: data.role });
// Map positioning is separate — errors here must NOT show credential message
try {
await _fetchAndApplyPort(data.access_token, data.role);
app.classList.remove('hidden'); // show app BEHIND login screen (z-index:9999)
window._mapUpdateSize?.(); // OL measures real container size
_applyPortToMap(); // position map at correct port
} catch (e) {
console.warn('[auth] map positioning error:', e);
app.classList.remove('hidden'); // show app anyway at default position
}
screen.classList.add('hidden'); // reveal map
afterLogin();
} catch {
errEl.textContent = 'Invalid username or password.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'LOGIN';
}
}
btn.addEventListener('click', doLogin);
document.getElementById('ls-pass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
}
function afterLogin() {
if (window.PortSearch) window.PortSearch.init();
Auth._renderBadge();
const sbUser = document.getElementById('sb-user');
if (sbUser && Auth.session) {
sbUser.textContent = `${Auth.session.nombre} · ${Auth.session.role}`;
}
}
// ── Event listeners ──────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initStartupLogin();
document.getElementById('btn-login-submit')?.addEventListener('click', () => Modal.submitLogin());
document.getElementById('btn-login-cancel')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-login')?.addEventListener('click', () => Modal.closeLogin());
document.getElementById('btn-close-edit')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('btn-edit-cancel')?.addEventListener('click', () => Modal.closeEdit());
document.getElementById('login-pass')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') Modal.submitLogin();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') _hideOverlay();
});
// (Click on the backdrop intentionally does NOT close modals — user must
// use the X or CLOSE button. Avoids losing unsaved Settings inputs.)
});
window.Auth = Auth;
window.Modal = Modal;