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::Deserialize;
use serde_json::Value; use serde_json::Value;
use crate::{AppState, db, intervals::IntervalsClient}; use crate::{AppState, db, html, intervals::IntervalsClient};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct OAuthCallback { pub struct OAuthCallback {
@ -209,7 +209,7 @@ button{{background:#2563eb;color:white;border:0;border-radius:8px;padding:.7rem
</div> </div>
</body> </body>
</html>"#, </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() (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 config;
mod db; mod db;
mod geo; mod geo;
mod html;
mod intervals; mod intervals;
mod leaderboard; mod leaderboard;
mod model; mod model;
@ -225,8 +226,8 @@ a {{
</p> </p>
</body> </body>
</html>"#, </html>"#,
web_escape(&session.display_name), html::escape(&session.display_name),
web_escape(&session.intervals_athlete_id), html::escape(&session.intervals_athlete_id),
url_segment(&session.intervals_athlete_id), url_segment(&session.intervals_athlete_id),
); );
@ -388,7 +389,7 @@ input[type="datetime-local"] {
html.push_str(&format!( html.push_str(&format!(
"<h1>Sync: {}</h1>", "<h1>Sync: {}</h1>",
web_escape(&session.display_name) html::escape(&session.display_name)
)); ));
html.push_str( html.push_str(
@ -465,8 +466,8 @@ input[type="datetime-local"] {
</form> </form>
</div>"#, </div>"#,
url_segment(&athlete_id), url_segment(&athlete_id),
web_escape(&oldest_input), html::escape(&oldest_input),
web_escape(&newest_input), html::escape(&newest_input),
)); ));
} }
@ -494,9 +495,9 @@ input[type="datetime-local"] {
{} · {} · {} {} · {} · {}
</div> </div>
</div>"#, </div>"#,
web_escape(&activity.name), html::escape(&activity.name),
web_escape(&activity.activity_type), html::escape(&activity.activity_type),
web_escape(activity.start_time.as_deref().unwrap_or("")), html::escape(activity.start_time.as_deref().unwrap_or("")),
distance, distance,
)); ));
@ -557,7 +558,7 @@ Aktivität {} wurde importiert.
</body> </body>
</html>"#, </html>"#,
url_segment(&athlete_id), url_segment(&athlete_id),
web_escape(&activity_id), html::escape(&activity_id),
url_segment(&athlete_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 { fn url_segment(value: &str) -> String {
urlencoding::encode(value).into_owned() urlencoding::encode(value).into_owned()
} }

View file

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