fix(reports): code-review findings — XSS, SMTP, blocking, unit link, email guard

- #1 XSS: escape user-controlled values (location name, baseline values, recent-
  report fields, SMTP status message) in the modals via the existing _mergeEsc
  helper — they were concatenated raw into innerHTML (stored XSS via location name).
- #2 SMTP: an unrecognized REPORT_SMTP_SECURITY no longer silently downgrades to a
  plaintext connection while still calling login() — it falls back to starttls and
  warns; warn on intentional security=none + auth.
- #3 scheduler: run the (blocking smtplib + Excel) nightly report in a worker thread
  (asyncio.to_thread + its own DB session) so it can't stall the loop that drives
  time-sensitive device cycles. New _run_one_report helper.
- #4 cycle ingest: set unit_id on the ingested data session (ingest_nrl_zip leaves
  it None) before dropping the empty placeholder, preserving the unit<->session link;
  repoint old_session_id at the real row.
- #7 robustness: wrap send_report_email in the orchestrator and run_nightly_report in
  /view + /run so a render/SMTP error returns a clean error instead of a raw 500
  after artifacts are written.

Verified: SMTP paths (typo->starttls, none, starttls, ssl), off-thread tick stamps
last_run_date + writes the file, /view 200, escaping wired, app imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 02:37:28 +00:00
parent ccb70698ba
commit fdd0426884
5 changed files with 120 additions and 57 deletions
@@ -188,7 +188,7 @@ function runNightReport(projectId) {
var em = res.j.email || {};
var emailMsg = em.sent ? 'emailed' : (em.dry_run ? 'email dry-run (SMTP not set)' : (em.error || 'email skipped'));
st.style.color = '#1a7f37';
st.innerHTML = 'Done — saved &amp; ' + emailMsg + '. <a href="' + res.j.view_url + '" target="_blank" class="underline">view</a>';
st.innerHTML = 'Done — saved &amp; ' + _mergeEsc(emailMsg) + '. <a href="' + _mergeEsc(res.j.view_url) + '" target="_blank" class="underline">view</a>';
loadRecentReports(projectId);
})
.catch(function (e) { st.style.color = '#b00020'; st.textContent = 'Error: ' + e; });
@@ -206,10 +206,10 @@ function loadRecentReports(projectId) {
}
box.innerHTML = j.reports.map(function (rp) {
var when = (rp.generated_at || '').replace('T', ' ').slice(0, 16);
var xlsx = rp.xlsx_url ? ' · <a href="' + rp.xlsx_url + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
var xlsx = rp.xlsx_url ? ' · <a href="' + _mergeEsc(rp.xlsx_url) + '" class="text-indigo-600 dark:text-indigo-400 hover:underline">Excel</a>' : '';
return '<div class="flex items-center justify-between px-3 py-2 text-sm">'
+ '<a href="' + rp.view_url + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + rp.night_date + '</a>'
+ '<span class="text-xs text-gray-400">' + when + ' UTC' + xlsx + '</span></div>';
+ '<a href="' + _mergeEsc(rp.view_url) + '" target="_blank" class="font-medium text-gray-800 dark:text-gray-200 hover:underline">Night of ' + _mergeEsc(rp.night_date) + '</a>'
+ '<span class="text-xs text-gray-400">' + _mergeEsc(when) + ' UTC' + xlsx + '</span></div>';
}).join('');
})
.catch(function () { box.innerHTML = '<div class="px-3 py-2 text-xs text-red-500">Failed to load.</div>'; });
@@ -380,12 +380,12 @@ function renderRefGrid() {
var rows = M.map(function (m) {
var cells = W.map(function (w) {
var v = (loc.values[w.key] && loc.values[w.key][m.key] != null) ? loc.values[w.key][m.key] : '';
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + v + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
return '<td class="px-1"><input type="number" step="0.1" id="' + _refId(loc.id, w.key, m.key) + '" value="' + _mergeEsc(v) + '" class="w-16 px-1.5 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm text-center"></td>';
}).join('');
return '<tr><td class="text-sm text-gray-700 dark:text-gray-300 pr-2">' + m.label + '</td>' + cells + '</tr>';
}).join('');
return '<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-2">'
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + loc.name + '</div>'
+ '<div class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-1">' + _mergeEsc(loc.name) + '</div>'
+ '<table class="w-full">' + head + rows + '</table></div>';
}).join('');
}