From d00136558aac0aa48fc9f202c6dfdd484bc0ab93 Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:31:12 +0000 Subject: [PATCH] Refuse cross-site preference changes and redirects off Twitter, YouTube, and Reddit. Show an account's country as a flag, and escape raw tags in posts and bios. Co-authored-by: Cursor --- config/teapot.example.toml | 2 +- flake.nix | 6 +- public/css/clean.css | 20 +- public/css/style.css | 4 +- public/css/tweet/base.css | 1 + public/js/site.js | 13 ++ src/api/budget.rs | 7 +- src/routes/middleware.rs | 25 +++ src/routes/preferences.rs | 5 +- src/routes/redirect.rs | 54 +++++ src/utils/country.rs | 380 +++++++++++++++++++++++++++++++++++ src/utils/entity_expander.rs | 41 +++- src/utils/mod.rs | 1 + src/views/layout.rs | 6 +- src/views/profile.rs | 23 ++- src/views/rss.rs | 4 +- src/views/tweet.rs | 16 +- 17 files changed, 564 insertions(+), 44 deletions(-) create mode 100644 src/utils/country.rs diff --git a/config/teapot.example.toml b/config/teapot.example.toml index d381e3f..c691e28 100644 --- a/config/teapot.example.toml +++ b/config/teapot.example.toml @@ -35,7 +35,7 @@ enableRSS = true # static assets are never charged, since they cost no upstream call. clientBudget = true -# Peers whose X-Forwarded-For and X-Real-IP name the real client. Anything not +# Peers whose X-Forwarded-For names the real client. Anything not # listed is billed by its socket address, so behind a reverse proxy this MUST # list that proxy or every visitor shares one budget. The header is read right # to left past trusted hops, since proxies append rather than replace. diff --git a/flake.nix b/flake.nix index 06dcb18..0a5b30a 100644 --- a/flake.nix +++ b/flake.nix @@ -211,9 +211,9 @@ default = [ ]; example = [ "::1" ]; description = '' - Peers whose X-Forwarded-For and X-Real-IP headers name the real - client. Any other peer is billed by its socket address, since - those headers are otherwise caller-controlled. + Peers whose X-Forwarded-For header names the real client. Any + other peer is billed by its socket address, since the header is + otherwise caller-controlled. ''; }; clientBudgetMessage = lib.mkOption { diff --git a/public/css/clean.css b/public/css/clean.css index 5944622..01312bd 100644 --- a/public/css/clean.css +++ b/public/css/clean.css @@ -266,10 +266,12 @@ main .container { vertical-align: middle; } .verified-icon svg { display: block; width: 15px; height: 15px; fill: currentColor; } +.verified-icon .verified-badge-bg { fill: currentColor; } +.verified-icon .verified-badge-check { fill: #fff; } .verified-icon.blue { color: var(--verified_blue); } .verified-icon.business { color: var(--verified_business); } .verified-icon.government { color: var(--verified_government); } -.tweet-content { color: var(--text); } +.tweet-content { color: var(--text); font-weight: 400; } .tweet-stats { color: var(--muted); } .retweet-header, .pinned { color: var(--muted); font-size: 13px; } .tweet-link:hover { background: transparent; } @@ -283,6 +285,10 @@ main .container { pointer-events: all; } .tweet-account-trigger[open] { z-index: 90; } +.timeline:has(.tweet-account-trigger[open]), +.quote:has(.tweet-account-trigger[open]) { + overflow: visible; +} .timeline-item:has(.tweet-account-trigger[open]) { z-index: 95; overflow: visible; @@ -648,6 +654,18 @@ main .container { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent); } +.account-signal-flag { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + font-size: 13px; + line-height: 1; +} +.account-signal > .icon-location, +.tweet-account-location > .icon-location { + color: var(--accent); +} .profile-card-extra { display: block; } .profile-card-extra .profile-bio { margin: 16px 0 12px; font-size: 14px; } .profile-card-extra-links { margin-top: 14px; padding-top: 12px; } diff --git a/public/css/style.css b/public/css/style.css index 79570d3..a92eaff 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,4 +1,4 @@ -@import "tweet/base.css?teapawt3"; +@import "tweet/base.css?teapawt4"; @import "profile/base.css?teapawt1"; @import "general.css"; @import "navbar.css"; @@ -23,7 +23,7 @@ body { outline: unset; margin: 0; text-decoration: none; - font-weight: 300; + font-weight: 400; } h1 { diff --git a/public/css/tweet/base.css b/public/css/tweet/base.css index 4b13325..5584775 100644 --- a/public/css/tweet/base.css +++ b/public/css/tweet/base.css @@ -16,6 +16,7 @@ .tweet-content { line-height: 1.3em; + font-weight: 450; pointer-events: all; display: inline; } diff --git a/public/js/site.js b/public/js/site.js index 02c7c4f..eb5ce50 100644 --- a/public/js/site.js +++ b/public/js/site.js @@ -1,6 +1,19 @@ "use strict"; document.addEventListener("click", async (event) => { + const target = event.target; + const accountDetails = target instanceof Element + ? target.closest(".tweet-account-trigger, .account-context-trigger") + : null; + + if (!accountDetails) { + document + .querySelectorAll(".tweet-account-trigger[open], .account-context-trigger[open]") + .forEach((details) => { + details.removeAttribute("open"); + }); + } + const button = event.target.closest(".copy-btn"); if (!button) return; diff --git a/src/api/budget.rs b/src/api/budget.rs index c43c9ff..f89788e 100644 --- a/src/api/budget.rs +++ b/src/api/budget.rs @@ -196,12 +196,7 @@ pub fn client_from(peer: IpAddr, headers: &HeaderMap, trusted: &[IpAddr]) -> Cli .filter_map(|entry| entry.trim().parse::().ok()) .find(|address| !trusted.contains(address)); - let real_ip = || { - let value = headers.get("x-real-ip")?.to_str().ok()?; - value.trim().parse::().ok() - }; - - ClientKey::from_ip(forwarded.or_else(real_ip).unwrap_or(peer)) + ClientKey::from_ip(forwarded.unwrap_or(peer)) } impl ClientBudget { diff --git a/src/routes/middleware.rs b/src/routes/middleware.rs index f5a9211..f33e0b6 100644 --- a/src/routes/middleware.rs +++ b/src/routes/middleware.rs @@ -48,8 +48,33 @@ pub async fn client_middleware( budget::CLIENT.scope(client, next.run(request)).await } +/// Browsers without `Sec-Fetch-Site` predate 2023, and non-browser clients +/// cannot be lured into a request, so a missing header passes. +fn is_cross_site(request: &Request) -> bool { + request + .headers() + .get("sec-fetch-site") + .is_some_and(|value| !matches!(value.as_bytes(), b"same-origin" | b"none")) +} + +/// Refuse preference writes submitted from another site's form. +pub async fn same_origin_guard(request: Request, next: Next) -> Response { + if is_cross_site(&request) { + return Error::InvalidUrl("Settings can only be changed from this site".into()) + .into_response(); + } + next.run(request).await +} + /// Middleware that applies `?prefs=` URL parameter overrides. +/// +/// A link from another site could otherwise switch off the media proxy or +/// point redirects elsewhere, so cross-site navigations get no overrides. pub async fn prefs_middleware(mut request: Request, next: Next) -> Response { + if is_cross_site(&request) { + return next.run(request).await; + } + let uri = request.uri().clone(); let query_string = uri.query().unwrap_or(""); diff --git a/src/routes/preferences.rs b/src/routes/preferences.rs index 1f9b4ed..c8301f4 100644 --- a/src/routes/preferences.rs +++ b/src/routes/preferences.rs @@ -4,6 +4,7 @@ use axum::{ Form, Router, extract::{Query, State}, http::{HeaderMap, header::REFERER}, + middleware, response::{Html, IntoResponse as _, Redirect, Response}, routing::{get, post}, }; @@ -16,6 +17,7 @@ use crate::{ AppState, config::Config, error::Result, + routes::middleware::same_origin_guard, types::Prefs, views::{layout, preferences as pref_view}, }; @@ -134,12 +136,13 @@ fn make_cookie( pub fn router() -> Router { Router::new() - .route("/settings", get(settings)) .route("/saveprefs", post(save_prefs)) .route("/resetprefs", post(reset_prefs)) .route("/enablemp4", post(enable_mp4)) .route("/enablehls", post(enable_hls)) .route("/ignorefirefoxwarning", post(ignore_firefox_warning)) + .route_layer(middleware::from_fn(same_origin_guard)) + .route("/settings", get(settings)) } #[derive(Debug, Deserialize)] diff --git a/src/routes/redirect.rs b/src/routes/redirect.rs index f972778..74e91d6 100644 --- a/src/routes/redirect.rs +++ b/src/routes/redirect.rs @@ -1,6 +1,7 @@ use axum::{ Router, extract::{Path, Query, State}, + http::Uri, response::{IntoResponse as _, Redirect, Response}, routing::get, }; @@ -40,6 +41,9 @@ async fn redirect_handler( // Decode URL if needed let decoded_url = percent_decode(url); + if !is_redirectable(&decoded_url) { + return Err(Error::InvalidUrl("Redirect target is not allowed".into())); + } // Get user preferences for URL replacements let prefs = Prefs::from_cookies(&jar, &state.config); @@ -50,6 +54,33 @@ async fn redirect_handler( Ok(Redirect::to(&transformed).into_response()) } +fn is_redirectable(url: &str) -> bool { + const HOSTS: [&str; 5] = [ + "twitter.com", + "x.com", + "youtube.com", + "youtu.be", + "reddit.com", + ]; + + let Ok(uri) = url.parse::() else { + return false; + }; + if !matches!(uri.scheme_str(), Some("https" | "http")) { + return false; + } + let Some(host) = uri.host().map(str::to_ascii_lowercase) else { + return false; + }; + + HOSTS.iter().any(|allowed| { + host == *allowed + || host + .strip_suffix(allowed) + .is_some_and(|sub| sub.ends_with('.')) + }) +} + /// Resolve `t.co` short URLs. async fn tco_resolver( State(state): State, @@ -160,3 +191,26 @@ fn percent_decode(url: &str) -> String { .unwrap_or_else(|_| url.into()) .into_owned() } + +#[cfg(test)] +mod tests { + use super::is_redirectable; + + #[test] + fn redirect_allows_twitter_youtube_and_reddit() { + assert!(is_redirectable("https://x.com/SpaceX/status/1")); + assert!(is_redirectable("https://mobile.twitter.com/home")); + assert!(is_redirectable("http://www.youtube.com/watch?v=abc")); + assert!(is_redirectable("https://youtu.be/abc")); + assert!(is_redirectable("https://old.reddit.com/r/spacex")); + } + + #[test] + fn redirect_rejects_other_targets() { + assert!(!is_redirectable("https://evil.com")); + assert!(!is_redirectable("https://twitter.com.evil.com")); + assert!(!is_redirectable("javascript:alert(1)")); + assert!(!is_redirectable("https://evil.com#@x.com")); + assert!(!is_redirectable("/local")); + } +} diff --git a/src/utils/country.rs b/src/utils/country.rs new file mode 100644 index 0000000..507e1c6 --- /dev/null +++ b/src/utils/country.rs @@ -0,0 +1,380 @@ +use std::fmt; + +/// Unicode regional indicator symbols start at U+1F1E6 (letter A). +const REGIONAL_INDICATOR_A: u32 = 0x1F1E6; + +/// An ISO 3166-1 alpha-2 code, rendered as the regional indicator pair that +/// browsers and system fonts draw as a flag. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Flag([u8; 2]); + +impl fmt::Display for Flag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for letter in self.0 { + let indicator = char::from_u32(REGIONAL_INDICATOR_A + u32::from(letter - b'A')) + .expect("an alpha-2 letter offsets into the regional indicator block"); + write!(f, "{indicator}")?; + } + Ok(()) + } +} + +/// ISO 3166-1 names and their common-name variants, lowercased and sorted by +/// code point so `flag` can binary search them. +static COUNTRIES: &[(&str, [u8; 2])] = &[ + ("afghanistan", *b"AF"), + ("albania", *b"AL"), + ("algeria", *b"DZ"), + ("american samoa", *b"AS"), + ("andorra", *b"AD"), + ("angola", *b"AO"), + ("anguilla", *b"AI"), + ("antarctica", *b"AQ"), + ("antigua and barbuda", *b"AG"), + ("argentina", *b"AR"), + ("armenia", *b"AM"), + ("aruba", *b"AW"), + ("australia", *b"AU"), + ("austria", *b"AT"), + ("azerbaijan", *b"AZ"), + ("bahamas", *b"BS"), + ("bahrain", *b"BH"), + ("bangladesh", *b"BD"), + ("barbados", *b"BB"), + ("belarus", *b"BY"), + ("belgium", *b"BE"), + ("belize", *b"BZ"), + ("benin", *b"BJ"), + ("bermuda", *b"BM"), + ("bhutan", *b"BT"), + ("bolivia", *b"BO"), + ("bolivia, plurinational state of", *b"BO"), + ("bonaire, sint eustatius and saba", *b"BQ"), + ("bosnia and herzegovina", *b"BA"), + ("botswana", *b"BW"), + ("bouvet island", *b"BV"), + ("brazil", *b"BR"), + ("british indian ocean territory", *b"IO"), + ("brunei darussalam", *b"BN"), + ("bulgaria", *b"BG"), + ("burkina faso", *b"BF"), + ("burundi", *b"BI"), + ("cabo verde", *b"CV"), + ("cambodia", *b"KH"), + ("cameroon", *b"CM"), + ("canada", *b"CA"), + ("cayman islands", *b"KY"), + ("central african republic", *b"CF"), + ("chad", *b"TD"), + ("chile", *b"CL"), + ("china", *b"CN"), + ("christmas island", *b"CX"), + ("cocos (keeling) islands", *b"CC"), + ("colombia", *b"CO"), + ("comoros", *b"KM"), + ("congo", *b"CG"), + ("congo, the democratic republic of the", *b"CD"), + ("cook islands", *b"CK"), + ("costa rica", *b"CR"), + ("croatia", *b"HR"), + ("cuba", *b"CU"), + ("curaçao", *b"CW"), + ("cyprus", *b"CY"), + ("czechia", *b"CZ"), + ("côte d'ivoire", *b"CI"), + ("denmark", *b"DK"), + ("djibouti", *b"DJ"), + ("dominica", *b"DM"), + ("dominican republic", *b"DO"), + ("ecuador", *b"EC"), + ("egypt", *b"EG"), + ("el salvador", *b"SV"), + ("equatorial guinea", *b"GQ"), + ("eritrea", *b"ER"), + ("estonia", *b"EE"), + ("eswatini", *b"SZ"), + ("ethiopia", *b"ET"), + ("falkland islands (malvinas)", *b"FK"), + ("faroe islands", *b"FO"), + ("fiji", *b"FJ"), + ("finland", *b"FI"), + ("france", *b"FR"), + ("french guiana", *b"GF"), + ("french polynesia", *b"PF"), + ("french southern territories", *b"TF"), + ("gabon", *b"GA"), + ("gambia", *b"GM"), + ("georgia", *b"GE"), + ("germany", *b"DE"), + ("ghana", *b"GH"), + ("gibraltar", *b"GI"), + ("greece", *b"GR"), + ("greenland", *b"GL"), + ("grenada", *b"GD"), + ("guadeloupe", *b"GP"), + ("guam", *b"GU"), + ("guatemala", *b"GT"), + ("guernsey", *b"GG"), + ("guinea", *b"GN"), + ("guinea-bissau", *b"GW"), + ("guyana", *b"GY"), + ("haiti", *b"HT"), + ("heard island and mcdonald islands", *b"HM"), + ("holy see (vatican city state)", *b"VA"), + ("honduras", *b"HN"), + ("hong kong", *b"HK"), + ("hungary", *b"HU"), + ("iceland", *b"IS"), + ("india", *b"IN"), + ("indonesia", *b"ID"), + ("iran", *b"IR"), + ("iran, islamic republic of", *b"IR"), + ("iraq", *b"IQ"), + ("ireland", *b"IE"), + ("isle of man", *b"IM"), + ("israel", *b"IL"), + ("italy", *b"IT"), + ("jamaica", *b"JM"), + ("japan", *b"JP"), + ("jersey", *b"JE"), + ("jordan", *b"JO"), + ("kazakhstan", *b"KZ"), + ("kenya", *b"KE"), + ("kiribati", *b"KI"), + ("korea, democratic people's republic of", *b"KP"), + ("korea, republic of", *b"KR"), + ("kuwait", *b"KW"), + ("kyrgyzstan", *b"KG"), + ("lao people's democratic republic", *b"LA"), + ("laos", *b"LA"), + ("latvia", *b"LV"), + ("lebanon", *b"LB"), + ("lesotho", *b"LS"), + ("liberia", *b"LR"), + ("libya", *b"LY"), + ("liechtenstein", *b"LI"), + ("lithuania", *b"LT"), + ("luxembourg", *b"LU"), + ("macao", *b"MO"), + ("madagascar", *b"MG"), + ("malawi", *b"MW"), + ("malaysia", *b"MY"), + ("maldives", *b"MV"), + ("mali", *b"ML"), + ("malta", *b"MT"), + ("marshall islands", *b"MH"), + ("martinique", *b"MQ"), + ("mauritania", *b"MR"), + ("mauritius", *b"MU"), + ("mayotte", *b"YT"), + ("mexico", *b"MX"), + ("micronesia, federated states of", *b"FM"), + ("moldova", *b"MD"), + ("moldova, republic of", *b"MD"), + ("monaco", *b"MC"), + ("mongolia", *b"MN"), + ("montenegro", *b"ME"), + ("montserrat", *b"MS"), + ("morocco", *b"MA"), + ("mozambique", *b"MZ"), + ("myanmar", *b"MM"), + ("namibia", *b"NA"), + ("nauru", *b"NR"), + ("nepal", *b"NP"), + ("netherlands", *b"NL"), + ("new caledonia", *b"NC"), + ("new zealand", *b"NZ"), + ("nicaragua", *b"NI"), + ("niger", *b"NE"), + ("nigeria", *b"NG"), + ("niue", *b"NU"), + ("norfolk island", *b"NF"), + ("north korea", *b"KP"), + ("north macedonia", *b"MK"), + ("northern mariana islands", *b"MP"), + ("norway", *b"NO"), + ("oman", *b"OM"), + ("pakistan", *b"PK"), + ("palau", *b"PW"), + ("palestine, state of", *b"PS"), + ("panama", *b"PA"), + ("papua new guinea", *b"PG"), + ("paraguay", *b"PY"), + ("peru", *b"PE"), + ("philippines", *b"PH"), + ("pitcairn", *b"PN"), + ("poland", *b"PL"), + ("portugal", *b"PT"), + ("puerto rico", *b"PR"), + ("qatar", *b"QA"), + ("romania", *b"RO"), + ("russian federation", *b"RU"), + ("rwanda", *b"RW"), + ("réunion", *b"RE"), + ("saint barthélemy", *b"BL"), + ("saint helena, ascension and tristan da cunha", *b"SH"), + ("saint kitts and nevis", *b"KN"), + ("saint lucia", *b"LC"), + ("saint martin (french part)", *b"MF"), + ("saint pierre and miquelon", *b"PM"), + ("saint vincent and the grenadines", *b"VC"), + ("samoa", *b"WS"), + ("san marino", *b"SM"), + ("sao tome and principe", *b"ST"), + ("saudi arabia", *b"SA"), + ("senegal", *b"SN"), + ("serbia", *b"RS"), + ("seychelles", *b"SC"), + ("sierra leone", *b"SL"), + ("singapore", *b"SG"), + ("sint maarten (dutch part)", *b"SX"), + ("slovakia", *b"SK"), + ("slovenia", *b"SI"), + ("solomon islands", *b"SB"), + ("somalia", *b"SO"), + ("south africa", *b"ZA"), + ("south georgia and the south sandwich islands", *b"GS"), + ("south korea", *b"KR"), + ("south sudan", *b"SS"), + ("spain", *b"ES"), + ("sri lanka", *b"LK"), + ("sudan", *b"SD"), + ("suriname", *b"SR"), + ("svalbard and jan mayen", *b"SJ"), + ("sweden", *b"SE"), + ("switzerland", *b"CH"), + ("syria", *b"SY"), + ("syrian arab republic", *b"SY"), + ("taiwan", *b"TW"), + ("taiwan, province of china", *b"TW"), + ("tajikistan", *b"TJ"), + ("tanzania", *b"TZ"), + ("tanzania, united republic of", *b"TZ"), + ("thailand", *b"TH"), + ("timor-leste", *b"TL"), + ("togo", *b"TG"), + ("tokelau", *b"TK"), + ("tonga", *b"TO"), + ("trinidad and tobago", *b"TT"), + ("tunisia", *b"TN"), + ("turkmenistan", *b"TM"), + ("turks and caicos islands", *b"TC"), + ("tuvalu", *b"TV"), + ("türkiye", *b"TR"), + ("uganda", *b"UG"), + ("ukraine", *b"UA"), + ("united arab emirates", *b"AE"), + ("united kingdom", *b"GB"), + ("united states", *b"US"), + ("united states minor outlying islands", *b"UM"), + ("uruguay", *b"UY"), + ("uzbekistan", *b"UZ"), + ("vanuatu", *b"VU"), + ("venezuela", *b"VE"), + ("venezuela, bolivarian republic of", *b"VE"), + ("viet nam", *b"VN"), + ("vietnam", *b"VN"), + ("virgin islands, british", *b"VG"), + ("virgin islands, u.s.", *b"VI"), + ("wallis and futuna", *b"WF"), + ("western sahara", *b"EH"), + ("yemen", *b"YE"), + ("zambia", *b"ZM"), + ("zimbabwe", *b"ZW"), + ("åland islands", *b"AX"), +]; + +/// Colloquial English names for countries whose ISO name is a formal or +/// inverted one, plus the abbreviations a caller may pass by hand. Sorted +/// alongside `COUNTRIES`. +static ALIASES: &[(&str, [u8; 2])] = &[ + ("cape verde", *b"CV"), + ("czech republic", *b"CZ"), + ("democratic republic of the congo", *b"CD"), + ("east timor", *b"TL"), + ("great britain", *b"GB"), + ("ivory coast", *b"CI"), + ("korea", *b"KR"), + ("macau", *b"MO"), + ("palestine", *b"PS"), + ("republic of korea", *b"KR"), + ("republic of the congo", *b"CG"), + ("russia", *b"RU"), + ("swaziland", *b"SZ"), + ("the netherlands", *b"NL"), + ("turkey", *b"TR"), + ("uk", *b"GB"), + ("united states of america", *b"US"), + ("usa", *b"US"), + ("vatican city", *b"VA"), +]; + +/// Expand X's ambiguous Korea label for display. +pub fn display_name(country: &str) -> &str { + if country.trim().eq_ignore_ascii_case("korea") { + "South Korea" + } else { + country + } +} + +/// Resolve an English country name, or a bare alpha-2 code, to its flag. +/// X also reports broad regions such as "Europe", which have no flag. +pub fn flag(country: &str) -> Option { + let country = country.trim(); + let lowered = || country.chars().flat_map(char::to_lowercase); + let by_name = |table: &[(&str, [u8; 2])]| { + table + .binary_search_by(|entry| entry.0.chars().cmp(lowered())) + .ok() + .map(|index| Flag(table[index].1)) + }; + + by_name(COUNTRIES).or_else(|| by_name(ALIASES)).or_else(|| { + let code = <[u8; 2]>::try_from(country.as_bytes()) + .ok()? + .map(|letter| letter.to_ascii_uppercase()); + + COUNTRIES + .iter() + .any(|&(_, known)| known == code) + .then_some(Flag(code)) + }) +} + +#[cfg(test)] +mod tests { + use super::flag; + + #[test] + fn x_location_strings_resolve() { + for (location, expected) in [ + ("United States", Some("🇺🇸")), + ("United Kingdom", Some("🇬🇧")), + (" UK ", Some("🇬🇧")), + ("USA", Some("🇺🇸")), + ("br", Some("🇧🇷")), + ("Czechia", Some("🇨🇿")), + ("Czech Republic", Some("🇨🇿")), + ("Russia", Some("🇷🇺")), + ("TÜRKIYE", Some("🇹🇷")), + ("South Korea", Some("🇰🇷")), + (" KOREA ", Some("🇰🇷")), + ("North Korea", Some("🇰🇵")), + ("Vietnam", Some("🇻🇳")), + ("Côte d'Ivoire", Some("🇨🇮")), + ("Ivory Coast", Some("🇨🇮")), + ("Åland Islands", Some("🇦🇽")), + ("Europe", None), + ("North America", None), + ("Southeast Asia", None), + ("", None), + ("ZZ", None), + ("1A", None), + ("🇺🇸", None), + ] { + let found = flag(location).map(|found| found.to_string()); + assert_eq!(found.as_deref(), expected, "{location}"); + } + } +} diff --git a/src/utils/entity_expander.rs b/src/utils/entity_expander.rs index 0ca9e55..bab8b77 100644 --- a/src/utils/entity_expander.rs +++ b/src/utils/entity_expander.rs @@ -3,9 +3,9 @@ //! Converts @mentions, #hashtags, and URLs into clickable HTML links. //! //! Twitter's API returns `full_text` with HTML entities already escaped -//! (`&`, `<`, `>`, etc.). Text between entities is passed through -//! as-is since it's already HTML-safe. Only entity *attributes* (URLs, -//! display names) need escaping because they come from our own processing. +//! (`&`, `<`, `>`, etc.), so existing entities are kept. Raw `<` and +//! `>` are escaped anyway because `note_tweet` text and bios are not +//! guaranteed to arrive escaped. use std::{borrow::Cow, fmt::Write as _, sync::LazyLock}; @@ -47,7 +47,7 @@ pub fn expand_entities_for_x(text: &str, entities: &[Entity]) -> String { fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) -> String { if entities.is_empty() { - return text.to_owned(); + return escape_tags(text).into_owned(); } // Sort entities by start position @@ -81,13 +81,13 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) continue; }; - // Add text before this entity (already HTML-escaped by Twitter) + // Add text before this entity. Existing entities stay; raw tags do not. if start_char > last_end_char { - result.push_str(&text[last_end_byte..start_byte]); + result.push_str(&escape_tags(&text[last_end_byte..start_byte])); } // Add the entity as an HTML link - let entity_text = &text[start_byte..end_byte]; + let entity_text = escape_tags(&text[start_byte..end_byte]); match entity.kind { EntityKind::Mention => { if link_to_x { @@ -147,11 +147,11 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) last_end_char = end_char; } - // Add remaining text (already HTML-escaped by Twitter) + // Add remaining text, escaping any raw tags in it. if last_end_char < char_count && let Some(last_end_byte) = char_to_byte_index(text, last_end_char) { - result.push_str(&text[last_end_byte..]); + result.push_str(&escape_tags(&text[last_end_byte..])); } result @@ -162,7 +162,8 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) /// Operates directly on Twitter's pre-escaped text. The `HASHTAG_RE` pattern /// uses `[^\w&;]` to avoid matching HTML entities like `'`. pub fn expand_with_regex(text: &str) -> String { - let result = MENTION_RE.replace_all(text, r#"$1@$2"#); + let escaped = escape_tags(text); + let result = MENTION_RE.replace_all(&escaped, r#"$1@$2"#); let result = HASHTAG_RE.replace_all(&result, r#"$1$2$3"#); result.into_owned() } @@ -191,6 +192,14 @@ pub fn html_escape(text: &str) -> Cow<'_, str> { Cow::Owned(result) } +/// Escape only `<` and `>`, leaving X's existing `&`-style entities intact. +pub fn escape_tags(text: &str) -> Cow<'_, str> { + if !text.contains(['<', '>']) { + return Cow::Borrowed(text); + } + Cow::Owned(text.replace('<', "<").replace('>', ">")) +} + /// URL encode a string (delegates to formatters). fn url_encode(text: &str) -> String { super::formatters::url_encode(text) @@ -351,4 +360,16 @@ mod tests { let result = expand_entities("A & B < C > D", &[]); assert_eq!(result, "A & B < C > D"); } + + #[test] + fn raw_angle_brackets_are_escaped() { + let result = expand_entities("hi ", &[]); + assert!(!result.contains("