Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions tp-webapp/src/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,25 @@
//! All manually-added segments are created with:
//! - `probability = 1.0`
//! - `origin = PathOrigin::Manual`
//! - `gnss_start_index = gnss_end_index = 0`
//! - `gnss_start_index = gnss_end_index` = the adjacent segment's end index (append) or
//! start index (prepend), so GNSS ordering invariants are preserved
//! - `start_intrinsic = 0.0`, `end_intrinsic = 1.0`

use tp_lib_core::{AssociatedNetElement, PathOrigin, RailwayNetwork, TrainPath};

/// Build a manual [`AssociatedNetElement`] with fixed invariants.
fn manual_segment(netelement_id: String) -> AssociatedNetElement {
fn manual_segment(
netelement_id: String,
gnss_start_index: usize,
gnss_end_index: usize,
) -> AssociatedNetElement {
let mut seg = AssociatedNetElement::new(
netelement_id,
1.0, // probability
0.0, // start_intrinsic
1.0, // end_intrinsic
0, // gnss_start_index
0, // gnss_end_index
gnss_start_index,
gnss_end_index,
)
.expect("invariants guarantee valid construction");
seg.origin = PathOrigin::Manual;
Expand Down Expand Up @@ -86,14 +91,21 @@ fn near(a: [f64; 2], b: [f64; 2]) -> bool {
/// - `path` – the current train path
pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPath) -> TrainPath {
// If the path is empty, just add the segment as the sole element.
// There is no adjacent segment, so GNSS indices start at 0.
if path.segments.is_empty() {
let mut new_path = path.clone();
new_path
.segments
.push(manual_segment(netelement_id.to_string()));
.push(manual_segment(netelement_id.to_string(), 0, 0));
return new_path;
}

// GNSS indices inherited from the adjacent segment at each potential insertion point.
// • Prepend: inherit the current first segment's gnss_start_index (preserves ordering).
// • Append: inherit the current last segment's gnss_end_index (preserves ordering).
let first_gnss = path.segments[0].gnss_start_index;
let last_gnss = path.segments[path.segments.len() - 1].gnss_end_index;

// Look up the new segment's endpoints from the network geometry.
let new_head = first_coord(network, netelement_id);
let new_tail = last_coord(network, netelement_id);
Expand All @@ -118,33 +130,42 @@ pub fn add_segment(netelement_id: &str, network: &RailwayNetwork, path: &TrainPa
};

let mut new_path = path.clone();
let seg = manual_segment(netelement_id.to_string());

match (can_prepend, can_append) {
(true, false) => {
new_path.segments.insert(0, seg);
new_path
.segments
.insert(0, manual_segment(netelement_id.to_string(), first_gnss, first_gnss));
}
(false, true) | (false, false) => {
// Append (also the fallback / disconnected case)
new_path.segments.push(seg);
new_path
.segments
.push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss));
}
(true, true) => {
// Ambiguous: segment connects to both ends.
// Pick the end where the new segment's midpoint is geometrically closest.
let new_mid = match (new_head, new_tail) {
(Some(h), Some(t)) => [(h[0] + t[0]) / 2.0, (h[1] + t[1]) / 2.0],
_ => {
new_path.segments.push(seg);
new_path
.segments
.push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss));
return new_path;
}
};
let d_head = path_head_start.map_or(f64::MAX, |h| dist2(new_mid, h));
let d_tail = path_tail_end.map_or(f64::MAX, |t| dist2(new_mid, t));

if d_head <= d_tail {
new_path.segments.insert(0, seg);
new_path
.segments
.insert(0, manual_segment(netelement_id.to_string(), first_gnss, first_gnss));
} else {
new_path.segments.push(seg);
new_path
.segments
.push(manual_segment(netelement_id.to_string(), last_gnss, last_gnss));
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions tp-webapp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub fn run_webapp_standalone(
let port_range = if port == 0 {
DEFAULT_PORTS
} else {
port..=port + 9
port..=port.saturating_add(9)
};

let (listener, bound_port) = bind_port(port_range)?;
Expand Down Expand Up @@ -139,7 +139,7 @@ pub fn run_webapp_integrated(
let port_range = if port == 0 {
DEFAULT_PORTS
} else {
port..=port + 9
port..=port.saturating_add(9)
};

let (listener, bound_port) = bind_port(port_range)?;
Expand Down
14 changes: 9 additions & 5 deletions tp-webapp/src/server/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ pub async fn put_path(
.segments
.into_iter()
.map(|seg| {
let origin = parse_origin(&seg.origin);
let origin = parse_origin(&seg.origin)?;
let mut element = tp_lib_core::AssociatedNetElement::new(
seg.netelement_id,
seg.probability,
Expand Down Expand Up @@ -243,10 +243,14 @@ pub async fn put_path(
.into_response()
}

fn parse_origin(s: &str) -> tp_lib_core::PathOrigin {
fn parse_origin(s: &str) -> Result<tp_lib_core::PathOrigin, String> {
match s {
"manual" => tp_lib_core::PathOrigin::Manual,
_ => tp_lib_core::PathOrigin::Algorithm,
"manual" => Ok(tp_lib_core::PathOrigin::Manual),
"algorithm" => Ok(tp_lib_core::PathOrigin::Algorithm),
other => Err(format!(
"unknown origin '{}': expected 'algorithm' or 'manual'",
other
)),
}
}

Expand Down Expand Up @@ -389,7 +393,7 @@ pub async fn post_abort(State(state): State<SharedState>) -> Response {
}

match state.confirm_tx.take() {
None => error_response(StatusCode::CONFLICT, "already confirmed"),
None => error_response(StatusCode::CONFLICT, "already handled"),
Some(tx) => {
let _ = tx.send(ConfirmResult::Aborted);
(StatusCode::OK, Json(json!({"ok": true}))).into_response()
Expand Down
29 changes: 26 additions & 3 deletions tp-webapp/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,27 @@ function confidenceColor(conf) {
return '#dc2626'; // red
}

/**
* Escape a value for safe insertion into an HTML context.
* Converts `&`, `<`, `>`, `"`, and `'` to their HTML entity equivalents.
* Use this whenever embedding untrusted data (e.g. netelement IDs from a
* network file) into Leaflet tooltip HTML to prevent XSS injection.
* @param {*} str - Value to escape (coerced to string).
* @returns {string}
*/
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function makeTooltip(props, seg) {
const conf = props.confidence != null ? (props.confidence * 100).toFixed(1) + '%' : '—';
const origin = props.origin ?? '—';
return `<b>${props.netelement_id}</b><br>Confidence: ${conf}<br>Origin: ${origin}`;
const origin = escapeHtml(props.origin ?? '—');
return `<b>${escapeHtml(props.netelement_id)}</b><br>Confidence: ${conf}<br>Origin: ${origin}`;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -221,7 +238,8 @@ function updateSidebar(pathData) {
async function onSave() {
const pathData = await apiFetch('/api/path');
if (pathData && pathData.segments.length === 0) {
if (!confirm('The path is empty. Save anyway?')) return;
setStatus('Cannot save: path is empty.');
return;
}
const result = await apiPost('/api/save');
if (result && result.ok) {
Expand All @@ -230,6 +248,11 @@ async function onSave() {
}

async function onConfirm() {
const pathData = await apiFetch('/api/path');
if (pathData && pathData.segments.length === 0) {
setStatus('Cannot confirm: path is empty.');
return;
}
const result = await apiPost('/api/confirm');
if (result && result.ok) {
setStatus('Path confirmed — you may close this window.');
Expand Down
49 changes: 49 additions & 0 deletions tp-webapp/tests/unit/routes_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,34 @@ async fn test_put_path_422_on_probability_out_of_range() {
assert_eq!(resp.status(), 422);
}

#[tokio::test]
async fn test_put_path_422_on_unknown_origin() {
let (base, _h) = start_server(standalone_state()).await;

let body = json!({
"segments": [{
"netelement_id": "NE001",
"probability": 0.9,
"start_intrinsic": 0.0,
"end_intrinsic": 1.0,
"gnss_start_index": 0,
"gnss_end_index": 0,
"origin": "manul" // typo: not a valid origin
}]
});

let resp = Client::new()
.put(format!("{base}/api/path"))
.json(&body)
.send()
.await
.unwrap();

assert_eq!(resp.status(), 422);
let json: Value = resp.json().await.unwrap();
assert_eq!(json["ok"], false);
}

// ---------------------------------------------------------------------------
// T011 — POST /api/save
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -403,6 +431,27 @@ async fn test_post_abort_409_in_standalone_mode() {
assert_eq!(resp.status(), 409);
}

#[tokio::test]
async fn test_post_abort_409_already_handled_when_tx_consumed() {
// confirm_tx is None → session already handled (confirmed or aborted)
let state = WebAppState {
mode: AppMode::Integrated,
confirm_tx: None,
..standalone_state()
};
let (base, _h) = start_server(state).await;

let resp = Client::new()
.post(format!("{base}/api/abort"))
.send()
.await
.unwrap();

assert_eq!(resp.status(), 409);
let json: Value = resp.json().await.unwrap();
assert_eq!(json["error"], "already handled");
}

// ---------------------------------------------------------------------------
// T030 — GET /api/gnss
// ---------------------------------------------------------------------------
Expand Down