mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 04:01:55 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
e93d659d21
commit
d00136558a
17 files changed
+564
-44
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+19
-1
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
.tweet-content {
|
||||
line-height: 1.3em;
|
||||
font-weight: 450;
|
||||
pointer-events: all;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
-6
@@ -196,12 +196,7 @@ pub fn client_from(peer: IpAddr, headers: &HeaderMap, trusted: &[IpAddr]) -> Cli
|
||||
.filter_map(|entry| entry.trim().parse::<IpAddr>().ok())
|
||||
.find(|address| !trusted.contains(address));
|
||||
|
||||
let real_ip = || {
|
||||
let value = headers.get("x-real-ip")?.to_str().ok()?;
|
||||
value.trim().parse::<IpAddr>().ok()
|
||||
};
|
||||
|
||||
ClientKey::from_ip(forwarded.or_else(real_ip).unwrap_or(peer))
|
||||
ClientKey::from_ip(forwarded.unwrap_or(peer))
|
||||
}
|
||||
|
||||
impl ClientBudget {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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<AppState> {
|
||||
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)]
|
||||
|
||||
@@ -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::<Uri>() 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<AppState>,
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Flag> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<a href="/$2">@$2</a>"#);
|
||||
let escaped = escape_tags(text);
|
||||
let result = MENTION_RE.replace_all(&escaped, r#"$1<a href="/$2">@$2</a>"#);
|
||||
let result = HASHTAG_RE.replace_all(&result, r#"$1<a href="/search?q=%23$3">$2$3</a>"#);
|
||||
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 <script>alert(1)</script>", &[]);
|
||||
assert!(!result.contains("<script>"));
|
||||
assert!(result.contains("<script>alert(1)</script>"));
|
||||
|
||||
let fallback = expand_with_regex("see <b>@user</b>");
|
||||
assert!(fallback.contains("<b>"));
|
||||
assert!(fallback.contains(r#"<a href="/user">@user</a>"#));
|
||||
assert!(fallback.contains("</b>"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod country;
|
||||
pub mod entity_expander;
|
||||
pub mod formatters;
|
||||
pub mod hmac;
|
||||
|
||||
+3
-3
@@ -5,8 +5,8 @@ use regex::Regex;
|
||||
|
||||
use crate::{config::Config, types::Prefs};
|
||||
|
||||
pub const STYLE_CSS: &str = "/css/style.css?teapawt9";
|
||||
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt35";
|
||||
pub const STYLE_CSS: &str = "/css/style.css?teapawt10";
|
||||
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt36";
|
||||
pub const FONTELLO_CSS: &str = "/css/fontello.css";
|
||||
|
||||
/// Builder for rendering a full page layout.
|
||||
@@ -255,7 +255,7 @@ impl<'a> PageLayout<'a> {
|
||||
// Scripts in <head> with defer
|
||||
script src="/js/htmx.min.js" defer="" {}
|
||||
script src="/js/webmcp.js" defer="" {}
|
||||
script src="/js/site.js" defer="" {}
|
||||
script src="/js/site.js?teapawt=1" defer="" {}
|
||||
script src="/js/threadContext.js" defer="" {}
|
||||
script src="/js/imageLightbox.js" defer="" {}
|
||||
@if infinite_scroll {
|
||||
|
||||
+13
-10
@@ -4,7 +4,10 @@ use super::timeline::{render_timeline_tabs, render_timeline_with_pinned_and_pref
|
||||
use crate::{
|
||||
config::Config,
|
||||
types::{GalleryPhoto, Prefs, Profile, TimelineKind, User},
|
||||
utils::formatters,
|
||||
utils::{
|
||||
country::{display_name as country_display_name, flag as country_flag},
|
||||
formatters,
|
||||
},
|
||||
views::renderutils::{
|
||||
gen_img, get_avatar_class, icon, link_user, parse_location, render_bio_html, short_link,
|
||||
verified_icon,
|
||||
@@ -198,18 +201,18 @@ fn render_account_context(user: &User) -> Markup {
|
||||
return Markup::default();
|
||||
}
|
||||
|
||||
let country = country_display_name(&user.account_based_in);
|
||||
|
||||
html! {
|
||||
details class="account-context-trigger" {
|
||||
summary class="account-signal" title="Show X-reported account information" {
|
||||
span class="account-signal-info" aria-hidden="true" { "i" }
|
||||
span class="sr-only" { "Account based in" }
|
||||
strong {
|
||||
@if user.account_based_in.is_empty() {
|
||||
"Unknown"
|
||||
} @else {
|
||||
(user.account_based_in)
|
||||
}
|
||||
@if let Some(flag) = country_flag(country) {
|
||||
span class="account-signal-flag" role="img" aria-label=(country) { (flag) }
|
||||
} @else {
|
||||
span class="icon-location" role="img"
|
||||
aria-label=(if country.is_empty() { "Account information" } else { country }) {}
|
||||
}
|
||||
span class="sr-only" { "Account based in" }
|
||||
@if user.location_accurate == Some(false) {
|
||||
span class="account-signal-warning"
|
||||
title="X says this location may be affected by a proxy or VPN" { "!" }
|
||||
@@ -224,7 +227,7 @@ fn render_account_context(user: &User) -> Markup {
|
||||
@if !user.account_based_in.is_empty() {
|
||||
div class="account-context-row" {
|
||||
dt { "Account based in" }
|
||||
dd { (user.account_based_in) }
|
||||
dd { (country) }
|
||||
}
|
||||
}
|
||||
@if !user.connection_source.is_empty() {
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ use std::{collections::HashSet, fmt::Write as _};
|
||||
use crate::{
|
||||
config::Config,
|
||||
types::{List, Tweet, Tweets, User},
|
||||
utils::{formatters, html_escape},
|
||||
utils::{escape_tags, formatters, html_escape},
|
||||
views::{
|
||||
layout::strip_html,
|
||||
renderutils::{community_note_to_html, tweet_link},
|
||||
@@ -285,7 +285,7 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config, proxy: bool) -> String {
|
||||
let url_prefix = config.url_prefix();
|
||||
|
||||
// Use replaceUrls with absolute URLs
|
||||
let text = formatters::replace_urls_abs(&tweet.text, config, url_prefix);
|
||||
let text = formatters::replace_urls_abs(&escape_tags(&tweet.text), config, url_prefix);
|
||||
let mut body = format!("<p>{}</p>", text.replace('\n', "<br>\n"));
|
||||
|
||||
// Photos
|
||||
|
||||
+11
-5
@@ -6,7 +6,7 @@ use crate::{
|
||||
Card, CardKind, Chain, Entity, Photo, Poll, Prefs, Tweet, TweetStats, User,
|
||||
VIDEO_CACHE_LIMIT_MESSAGE, Video,
|
||||
},
|
||||
utils::{entity_expander::expand_with_regex, expand_entities, formatters},
|
||||
utils::{country, entity_expander::expand_with_regex, expand_entities, formatters},
|
||||
views::renderutils::{
|
||||
button_referer, gen_img, get_avatar_class, get_medium_pic, get_small_pic, icon, link_user,
|
||||
parse_location, proxy_media, render_community_note, verified_icon,
|
||||
@@ -815,18 +815,24 @@ fn render_tweet_account_signal(user: &User) -> Markup {
|
||||
return Markup::default();
|
||||
}
|
||||
|
||||
let country = country::display_name(&user.account_based_in);
|
||||
|
||||
html! {
|
||||
details class="tweet-account-trigger" {
|
||||
summary class="tweet-account-location" title="Show X-reported account information" {
|
||||
span class="account-signal-dot" {}
|
||||
(user.account_based_in)
|
||||
summary class="tweet-account-location"
|
||||
title=(format!("Show X-reported account information: {country}")) {
|
||||
@if let Some(flag) = country::flag(country) {
|
||||
span class="account-signal-flag" role="img" aria-label=(country) { (flag) }
|
||||
} @else {
|
||||
span class="icon-location" role="img" aria-label=(country) {}
|
||||
}
|
||||
@if user.location_accurate == Some(false) {
|
||||
span class="tweet-account-warning"
|
||||
title="X says this location may be affected by a proxy or VPN" { "!" }
|
||||
}
|
||||
}
|
||||
div class="tweet-account-popover" {
|
||||
strong { (user.account_based_in) }
|
||||
strong { (country) }
|
||||
@if !user.connection_source.is_empty() {
|
||||
span { "Connected via " (user.connection_source) }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user