Webapp redesign: fixes from the code review

Blocker
- /reports/export returned 500: REPORT_EXPORT_RANGES and _months_ago were
  deleted with the old reports route. Restored.

Major
- A session without stage_index/stage_total took down /, /imaging and
  /imaging/tiles (int filter on Undefined). The templates default both;
  saving a LAPS password for a session that no longer exists is refused
  (it created a bare session).
- /reports/todo parsed every report four times (23 s at 700 reports).
  blancco_report.parse() now caches by path, mtime and size: 0.09 s.
- Stored XSS through the session serial in the Clear confirm (the
  browser decodes ' before running an inline handler). The message
  moves to data-confirm, and update_session() only accepts serials of
  letters, digits, '.', '_' and '-' (400 from /imaging/status otherwise).
- Reports without a document id and with odd characters in the file name
  got a key the mark routes rejected, so they could never be cleared.
  report_key() now always produces a valid key.

Minor
- failure_note() handles a list-valued erasure target (500 on the report
  and to-do pages).
- _recently_marked() tolerates naive, 'Z' and non-dict state entries.
- Key regexes use fullmatch (a trailing newline was accepted).
- Unreadable reports are no longer counted as to tick / to destroy; the
  reports page already flags them as Unreadable.
- Rollback review recounts restorable files after reading the backups.
- Enrollment: the startnet fallback package (no or empty ppkg.conf) and a
  quoted SOURCE_PPKG are protected too.
- Backup upload overwrite check is case-insensitive (SMB share) and
  replaces the existing file rather than adding a case twin.
- Overview polling keeps the last good rows on an error and pauses while
  the tab is hidden. A shown or typed LAPS password survives the floor's
  refresh.
- hardware_dismiss only redirects to same-site paths; tiles ?limit is
  clamped; the Updates overview survives an unreadable Dell scan or iPXE
  state; describe() uses aversion(); a client cannot label its session
  'inferred'.
This commit is contained in:
cproudlock
2026-10-14 14:42:21 -04:00
parent fd00d5ff89
commit 2bcc9d8016
10 changed files with 131 additions and 36 deletions

View File

@@ -135,18 +135,24 @@ def updates_overview():
"""One card per updater (Dell, iPXE, Blancco, BPRT) and the Dell applies
with their rollback state. Each card links to its own page."""
from services import blancco_update, bprt_sync, dell_updates, ipxe_update, updates_common
scan = dell_updates.last_scan()
try:
scan = dell_updates.last_scan()
except Exception: # unreadable scan.json: show "not checked" rather than a 500
scan = None
dell = {"checked": scan.get("checked") if scan else None,
"drivers": len(scan.get("drivers") or []) if scan else 0,
"bios": len(scan.get("bios") or []) if scan else 0,
"drv_updates": sum(1 for r in (scan.get("drivers") or []) if r.get("status") == "update") if scan else 0,
"bios_updates": sum(1 for r in (scan.get("bios") or []) if r.get("status") == "update") if scan else 0}
rows = ipxe_update.installed()
by_name = {r["name"]: r["info"] for r in rows}
latest = ipxe_update.last_check()
efi = by_name.get("ipxe.efi") or {}
ipxe = {"rows": rows, "version": efi.get("version"), "latest": latest,
"status": ipxe_update.compare(latest["tag"], efi.get("version")) if latest else None}
try:
rows = ipxe_update.installed()
by_name = {r["name"]: r["info"] for r in rows}
latest = ipxe_update.last_check()
efi = by_name.get("ipxe.efi") or {}
ipxe = {"rows": rows, "version": efi.get("version"), "latest": latest,
"status": ipxe_update.compare(latest["tag"], efi.get("version")) if latest else None}
except Exception as ex:
ipxe = {"rows": [], "version": None, "latest": None, "status": None, "error": str(ex)}
try:
blancco = {"current": blancco_update.current(), "staged": blancco_update.staged()}
except Exception as ex: # a broken release folder must not take the page down
@@ -561,9 +567,13 @@ def clonezilla_upload():
os.makedirs(config.CLONEZILLA_SHARE, exist_ok=True)
dest = os.path.join(config.CLONEZILLA_SHARE, filename)
# A backup is a whole disk image: never replace one by accident.
if os.path.exists(dest) and request.form.get("overwrite") != "1":
flash(f"{filename} already exists. Tick Replace the existing backup to overwrite it.", "danger")
# The share is SMB: names differ only in case are the same file to Windows.
clash = next((n for n in os.listdir(config.CLONEZILLA_SHARE) if n.lower() == filename.lower()), None)
if clash and request.form.get("overwrite") != "1":
flash(f"{clash} already exists. Tick Replace the existing backup to overwrite it.", "danger")
return redirect(url_for("clonezilla_backups"))
if clash:
dest = os.path.join(config.CLONEZILLA_SHARE, clash) # replace it, never a case twin
f.save(dest)
audit("BACKUP_UPLOAD", filename)
flash(f"Uploaded {filename} successfully.", "success")
@@ -688,6 +698,17 @@ def blancco_reports():
)
REPORT_EXPORT_RANGES = {"1m": 1, "3m": 3, "6m": 6, "12m": 12}
def _months_ago(now, months):
# Same day-of-month N months back, clamped (Mar 31 - 1m -> Feb 28/29).
import calendar
y, m = divmod(now.year * 12 + now.month - 1 - months, 12)
day = min(now.day, calendar.monthrange(y, m + 1)[1])
return now.replace(year=y, month=m + 1, day=day)
@app.route("/reports/export")
def blancco_export_reports():
import csv
@@ -810,10 +831,14 @@ def _recently_marked(state, year_items, hours=24):
for kind in blancco_evidence.TODO_KINDS:
rows = []
for key, rec in (state.get(kind) or {}).items():
if not isinstance(rec, dict):
continue # hand-edited state: nothing to undo
try:
ts = datetime.fromisoformat(str((rec or {}).get("ts")))
except ValueError:
ts = datetime.fromisoformat(str(rec.get("ts")).replace("Z", "+00:00"))
except (ValueError, TypeError):
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
if ts < cutoff:
continue
it = by_key.get(key)
@@ -895,7 +920,11 @@ def hardware_dismiss():
except ValueError:
abort(400)
audit("HARDWARE_DISMISS", f"{model} {'dismissed' if done else 'reopened'}")
return redirect(request.form.get("next") or url_for("hardware_page"))
nxt = request.form.get("next") or ""
# Only same-site paths ("/x", not "//host" or "https://...").
if not (nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt):
nxt = url_for("hardware_page")
return redirect(nxt)
@app.route("/reports/download/<filename>")
@@ -978,7 +1007,7 @@ def imaging_tiles_partial():
sessions, _hidden = imaging_status.hide_stale(sessions)
sessions = imaging_status.floor_order(sessions)
# ?limit=N (the Overview): the first N PCs, log-only bays left out.
limit = request.args.get("limit", type=int)
limit = max(0, request.args.get("limit", 0, type=int) or 0)
more = 0
if limit:
real = [x for x in sessions if x["floor_state"] != "logs"]
@@ -1055,6 +1084,8 @@ def imaging_status_post():
return jsonify({"error": "missing serial"}), 400
try:
state = imaging_status.update_session(payload)
except ValueError as ex:
return jsonify({"error": str(ex)}), 400
except Exception as ex:
audit("IMAGING_STATUS_ERROR", str(ex))
return jsonify({"error": str(ex)}), 500
@@ -1143,6 +1174,10 @@ def imaging_set_laps(serial):
except OSError: pass
raise
return {"ok": True, "cleared": True}
# Only for a bay that exists: saving for a cleared session would create a
# bare session file with no stage fields.
if not os.path.isfile(imaging_status._path_for(serial)):
return {"ok": False, "error": "no such session"}, 404
imaging_status.update_session({"serial": serial, "laps_password": pw})
return {"ok": True}
@@ -1202,6 +1237,7 @@ def enrollment():
mcl_archive=mcl_ppkg.archived(),
mcl_links=mcl_ppkg.image_links(),
active_ppkg=read_ppkg_conf(),
effective_ppkg=(read_ppkg_conf().get("SOURCE_PPKG", "").strip().strip('"').strip() or STARTNET_DEFAULT_PPKG),
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
@@ -1313,12 +1349,18 @@ def enrollment_download(filename):
return send_file(fpath, as_attachment=True)
# The package startnet.cmd uses when ppkg.conf is missing or names none
# (playbook/startnet.cmd: set SOURCE_PPKG=...).
STARTNET_DEFAULT_PPKG = "GCCH_Prod_SFLD_v4.16.ppkg"
@app.route("/enrollment/delete/<filename>", methods=["POST"])
def enrollment_delete(filename):
filename = secure_filename(filename)
fpath = os.path.join(config.ENROLLMENT_PPKG_DIR, filename)
active = read_ppkg_conf().get("SOURCE_PPKG", "")
if active and filename.lower() == active.lower():
# startnet.cmd falls back to this package when ppkg.conf names none.
active = read_ppkg_conf().get("SOURCE_PPKG", "").strip().strip('"').strip() or STARTNET_DEFAULT_PPKG
if filename.lower() == active.lower():
# startnet.cmd would keep pointing at a file that no longer exists.
flash(f"{filename} is the active package. Upload or activate another one before deleting it.",
"danger")