dedupe HTML escaping into a shared html module

web_escape (main.rs) and escape_html (auth.rs, web.rs) were three
byte-identical copies of the same & < > " ' replacement chain. Moved
into src/html.rs as html::escape and used from all three call sites.
This commit is contained in:
Claude 2026-08-13 02:10:56 +00:00 committed by Jonas Rabenstein
commit 84f5f24b6a
4 changed files with 48 additions and 64 deletions

View file

@ -10,7 +10,7 @@ use rand::{
use serde::Deserialize;
use serde_json::Value;
use crate::{AppState, db, intervals::IntervalsClient};
use crate::{AppState, db, html, intervals::IntervalsClient};
#[derive(Debug, Deserialize)]
pub struct OAuthCallback {
@ -209,7 +209,7 @@ button{{background:#2563eb;color:white;border:0;border-radius:8px;padding:.7rem
</div>
</body>
</html>"#,
escape_html(message)
html::escape(message)
)
}
@ -315,12 +315,3 @@ pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> impl IntoR
(jar, Redirect::to("/")).into_response()
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

10
src/html.rs Normal file
View file

@ -0,0 +1,10 @@
/// Escapes text for safe inclusion in HTML (element content and
/// double-quoted attribute values).
pub fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}

View file

@ -2,6 +2,7 @@ mod auth;
mod config;
mod db;
mod geo;
mod html;
mod intervals;
mod leaderboard;
mod model;
@ -225,8 +226,8 @@ a {{
</p>
</body>
</html>"#,
web_escape(&session.display_name),
web_escape(&session.intervals_athlete_id),
html::escape(&session.display_name),
html::escape(&session.intervals_athlete_id),
url_segment(&session.intervals_athlete_id),
);
@ -388,7 +389,7 @@ input[type="datetime-local"] {
html.push_str(&format!(
"<h1>Sync: {}</h1>",
web_escape(&session.display_name)
html::escape(&session.display_name)
));
html.push_str(
@ -465,8 +466,8 @@ input[type="datetime-local"] {
</form>
</div>"#,
url_segment(&athlete_id),
web_escape(&oldest_input),
web_escape(&newest_input),
html::escape(&oldest_input),
html::escape(&newest_input),
));
}
@ -494,9 +495,9 @@ input[type="datetime-local"] {
{} · {} · {}
</div>
</div>"#,
web_escape(&activity.name),
web_escape(&activity.activity_type),
web_escape(activity.start_time.as_deref().unwrap_or("")),
html::escape(&activity.name),
html::escape(&activity.activity_type),
html::escape(activity.start_time.as_deref().unwrap_or("")),
distance,
));
@ -557,7 +558,7 @@ Aktivität {} wurde importiert.
</body>
</html>"#,
url_segment(&athlete_id),
web_escape(&activity_id),
html::escape(&activity_id),
url_segment(&athlete_id),
)))
}
@ -728,15 +729,6 @@ fn http_error(error: anyhow::Error) -> (axum::http::StatusCode, String) {
)
}
fn web_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn url_segment(value: &str) -> String {
urlencoding::encode(value).into_owned()
}

View file

@ -13,7 +13,7 @@ use serde_json::Value;
use sqlx::Row;
use crate::{
AppState, db, leaderboard,
AppState, db, html, leaderboard,
model::{ActivityCrossing, LeaderboardGroup},
};
@ -379,7 +379,7 @@ fn render_index(
r#"<div>Angemeldet als <strong>{}</strong><br>
<a class="button secondary" href="/groups">Gruppen</a>
<a class="button" href="/logout">Logout</a></div>"#,
escape_html(name)
html::escape(name)
));
} else {
html.push_str(
@ -410,7 +410,7 @@ fn render_index(
"<option value=\"{}\"{}>{}</option>",
id,
selected,
escape_html(name)
html::escape(name)
));
}
@ -427,8 +427,8 @@ fn render_index(
html.push_str("<section class=\"card\">");
html.push_str(&format!(
"<h2>{} → {}</h2>",
escape_html(&group.from_county),
escape_html(&group.to_county)
html::escape(&group.from_county),
html::escape(&group.to_county)
));
html.push_str(&format!(
"<div class=\"small\">10-Minuten-Fenster · {} · Standort ±10 m</div>",
@ -454,7 +454,7 @@ fn render_index(
.map(|url| {
format!(
"<a href=\"{}\" target=\"_blank\" rel=\"noreferrer\">Intervall</a>",
escape_html(url)
html::escape(url)
)
})
.unwrap_or_else(|| "<span class=\"small\">—</span>".into());
@ -478,11 +478,11 @@ fn render_index(
<td><div class="mini-map" data-lat="{}" data-lon="{}"></div></td>
</tr>"#,
row.rank,
escape_html(&row.athlete),
escape_html(&row.crossing_time.to_rfc3339()),
html::escape(&row.athlete),
html::escape(&row.crossing_time.to_rfc3339()),
row.crossing_time.format("%H:%M:%S%.3f UTC"),
escape_html(&row.activity_url),
escape_html(&row.activity_id),
html::escape(&row.activity_url),
html::escape(&row.activity_id),
interval,
row.lat,
row.lon,
@ -522,18 +522,18 @@ fn render_activity(
<section class="card">
<h2>{}</h2>
<div class="small">Athlet: {} · Aktivität: {}</div>"#,
escape_html(title),
escape_html(&athlete_name),
escape_html(sport),
escape_html(title),
escape_html(&athlete_name),
escape_html(activity_id),
html::escape(title),
html::escape(&athlete_name),
html::escape(sport),
html::escape(title),
html::escape(&athlete_name),
html::escape(activity_id),
));
if let Some(start) = start_time {
html.push_str(&format!(
r#"<div class="small activity-start" data-time="{}">Start: {}</div>"#,
escape_html(&start.to_rfc3339()),
html::escape(&start.to_rfc3339()),
start.format("%Y-%m-%d %H:%M:%S UTC")
));
}
@ -552,7 +552,7 @@ fn render_activity(
})
})
.collect();
let points_json = escape_html(&serde_json::to_string(&points).unwrap_or_else(|_| "[]".into()));
let points_json = html::escape(&serde_json::to_string(&points).unwrap_or_else(|_| "[]".into()));
html.push_str(&format!(
r#"<div id="activity-map" class="activity-map" data-points="{}"></div>"#,
@ -573,7 +573,7 @@ fn render_activity(
.map(|url| {
format!(
"<a href=\"{}\" target=\"_blank\" rel=\"noreferrer\">Intervall</a>",
escape_html(url)
html::escape(url)
)
})
.unwrap_or_else(|| "".into());
@ -592,10 +592,10 @@ fn render_activity(
<td>{} {}</td><td class="rank">{}</td>
<td>{}</td><td>{}</td><td>{}</td></tr>"#,
index + 1,
escape_html(&crossing.crossing_time.to_rfc3339()),
html::escape(&crossing.crossing_time.to_rfc3339()),
crossing.crossing_time.format("%H:%M:%S%.3f UTC"),
escape_html(&crossing.from_county),
escape_html(&crossing.to_county),
html::escape(&crossing.from_county),
html::escape(&crossing.to_county),
crossing.rank,
power,
hr,
@ -626,7 +626,7 @@ fn render_groups(
html.push_str(&format!(
r#"<header><div><a href="/">← Leaderboard</a><h1>Leaderboard-Gruppen</h1>
<div class="small">Gruppen werden für {} verwaltet.</div></div></header>"#,
escape_html(owner_name)
html::escape(owner_name)
));
html.push_str(
@ -639,7 +639,7 @@ fn render_groups(
html.push_str(&format!(
r#"<label><input type="checkbox" name="members" value="{}"> {}</label>"#,
id,
escape_html(name)
html::escape(name)
));
}
html.push_str(r#"</div><button type="submit">Gruppe anlegen</button></form></section>"#);
@ -651,7 +651,7 @@ fn render_groups(
<input type="text" name="name" value="{}" required>
<div class="members">"#,
group_id,
escape_html(name)
html::escape(name)
));
for (id, athlete_name) in athletes {
@ -660,7 +660,7 @@ fn render_groups(
r#"<label><input type="checkbox" name="members" value="{}"{}> {}</label>"#,
id,
checked,
escape_html(athlete_name)
html::escape(athlete_name)
));
}
@ -746,12 +746,3 @@ if(activityMapEl){
</script>
</body></html>
"#;
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}