diff --git a/Cargo.toml b/Cargo.toml index 72d7c45..b83025d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ tower-http = { "fs", "cors", "compression-gzip", + "normalize-path", "set-header", "timeout", ], diff --git a/src/main.rs b/src/main.rs index a14f114..1ed51ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,8 @@ use std::{ use axum::{ Router, + ServiceExt, + extract::Request, http::{ StatusCode, header::HeaderValue, @@ -29,8 +31,12 @@ use axum::{ }; use axum::http::header; use tokio::net::TcpListener; -use tower::limit::ConcurrencyLimitLayer; +use tower::{ + Layer as _, + limit::ConcurrencyLimitLayer, +}; use tower_http::{ + normalize_path::NormalizePathLayer, services::{ ServeDir, ServeFile, @@ -240,9 +246,12 @@ async fn main() -> eyre::Result<()> { let listener = TcpListener::bind(addr).await?; tracing::info!("Listening on {addr}"); + // `/user/` and `/user/status/id/` are the same pages as the slash-free paths. axum::serve( listener, - app.into_make_service_with_connect_info::(), + ServiceExt::::into_make_service_with_connect_info::( + NormalizePathLayer::trim_trailing_slash().layer(app), + ), ) .await?; diff --git a/src/routes/embed.rs b/src/routes/embed.rs index 06dbbe8..1daa03b 100644 --- a/src/routes/embed.rs +++ b/src/routes/embed.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use axum::{ Router, extract::{ @@ -36,6 +38,7 @@ use crate::{ utils::formatters::format_relative_time, views::{ embed as embed_view, + embed::ActivityId, layout::strip_html, }, }; @@ -67,10 +70,10 @@ pub fn router() -> Router { // Legacy embed URL support (Twitter's old embed format) .route("/embed/Tweet.html", get(legacy_embed_redirect)) // ActivityPub endpoint for Discord multi-image support - .route("/users/{username}/statuses/{id}", get(activity_pub_status)) + .route("/users/{username}/statuses/{status}", get(activity_pub_status)) // Discord turns the Mastodon-looking discovery URL above into this // API request. The discovery URL itself is not the JSON endpoint. - .route("/api/v1/statuses/{id}", get(mastodon_status)) + .route("/api/v1/statuses/{status}", get(mastodon_status)) // oEmbed endpoints .route("/owoembed", get(oembed)) .route("/oembed", get(oembed_standard)) @@ -117,9 +120,10 @@ async fn video_embed(State(state): State, Path(id): Path) -> R /// Discord fetches this to get all media attachments for carousel display. async fn activity_pub_status( State(state): State, - Path((username, id)): Path<(String, String)>, + Path((username, status)): Path<(String, String)>, headers: HeaderMap, ) -> Result { + let activity = ActivityId::from_str(&status)?; let accept = headers .get(header::ACCEPT) .and_then(|hv| hv.to_str().ok()) @@ -138,23 +142,23 @@ async fn activity_pub_status( || accept.contains("application/activity+json") || accept.contains("application/ld+json") { - return activity_response(&state, &id).await; + return activity_response(&state, activity).await; } // Otherwise redirect to normal status page - Ok(Redirect::to(&format!("/{username}/status/{id}")).into_response()) + Ok(Redirect::to(&format!("/{username}/status/{}", activity.tweet())).into_response()) } /// Mastodon API v1 status endpoint used by Discord after discovery. async fn mastodon_status( State(state): State, - Path(id): Path, + Path(status): Path, ) -> Result { - activity_response(&state, &id).await + activity_response(&state, status.parse()?).await } -async fn activity_response(state: &AppState, id: &str) -> Result { - let conversation = cached_conversation(state, id).await?; +async fn activity_response(state: &AppState, activity: ActivityId) -> Result { + let conversation = cached_conversation(state, &activity.tweet().to_string()).await?; let reply_id = conversation.tweet.reply_id; let mut replied_to = if reply_id > 0 { conversation @@ -167,6 +171,9 @@ async fn activity_response(state: &AppState, id: &str) -> Result { None }; let mut tweet = conversation.tweet; + if let Some(idx) = activity.photo_idx() { + tweet.keep_only_photo(idx); + } if replied_to.is_none() && reply_id > 0 && reply_id != tweet.id { replied_to = cached_conversation(state, &reply_id.to_string()) @@ -185,13 +192,14 @@ async fn activity_response(state: &AppState, id: &str) -> Result { } } - let activity = replied_to.as_ref().map_or_else( + let mut note = replied_to.as_ref().map_or_else( || embed_view::build_activity_pub(&tweet, &state.config), |original| embed_view::build_activity_pub_with_reply(&tweet, Some(original), &state.config), ); + note.id = activity.to_string(); Ok(( [(header::CONTENT_TYPE, "application/json; charset=utf-8")], - Json(activity), + Json(note), ) .into_response()) } diff --git a/src/routes/status.rs b/src/routes/status.rs index 2704a93..a07363d 100644 --- a/src/routes/status.rs +++ b/src/routes/status.rs @@ -61,6 +61,28 @@ use crate::{ }, }; +const PREVIEW_AGENTS: [&str; 6] = [ + "Discordbot/", + "Slackbot", + "TelegramBot", + "WhatsApp", + "facebookexternalhit/", + "Twitterbot/", +]; + +#[derive(Debug, Deserialize)] +struct StatusParams { + username: String, + id: String, +} + +#[derive(Debug, Deserialize)] +struct PhotoParams { + username: String, + id: String, + idx: usize, +} + #[derive(Debug, Deserialize)] pub struct StatusQuery { pub cursor: Option, @@ -89,7 +111,7 @@ pub fn router() -> Router { ) .route( &format!("/{{username}}/{prefix}/{{id}}/photo/{{idx}}"), - get(status_media_redirect), + get(status_photo), ) .route( &format!("/{{username}}/{prefix}/{{id}}/video"), @@ -125,16 +147,47 @@ async fn thread_redirect(Path((username, id)): Path<(String, String)>) -> Respon Redirect::to(&format!("/{username}/status/{id}")).into_response() } -/// Redirect legacy media URLs (photo/video/history) to the main status page. -async fn status_media_redirect(Path((username, id)): Path<(String, String)>) -> Response { - Redirect::to(&format!("/{username}/status/{id}")).into_response() +/// Redirect legacy media URLs (photo/video) to the main status page. +/// Named fields so `/video/1` still redirects; the extra index is ignored. +async fn status_media_redirect(Path(params): Path) -> Response { + Redirect::to(&format!("/{}/status/{}", params.username, params.id)).into_response() +} + +/// Link unfurlers get the status page narrowed to the one photo, so the +/// preview shows that image instead of the whole post's media. +async fn status_photo( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + Path(params): Path, + Query(query): Query, +) -> Result { + let user_agent = headers + .get(header::USER_AGENT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + + if !PREVIEW_AGENTS + .iter() + .any(|agent| user_agent.contains(agent)) + { + return Ok( + Redirect::to(&format!("/{}/status/{}", params.username, params.id)).into_response(), + ); + } + + render_status( + state, + &jar, + &headers, + params.username, + params.id, + query, + params.idx.checked_sub(1), + ) + .await } -#[expect( - clippy::cognitive_complexity, - reason = "status handler has many conditional branches for caching, scrolling, pagination, and \ - translation" -)] async fn status( State(state): State, jar: CookieJar, @@ -142,9 +195,25 @@ async fn status( Path((username, id)): Path<(String, String)>, Query(query): Query, ) -> Result { - // Extract prefs from cookies - let prefs = Prefs::from_cookies(&jar, &state.config); - let discord_activity = headers + render_status(state, &jar, &headers, username, id, query, None).await +} + +#[expect( + clippy::cognitive_complexity, + reason = "status handler has many conditional branches for caching, scrolling, pagination, and \ + translation" +)] +async fn render_status( + state: AppState, + jar: &CookieJar, + headers: &HeaderMap, + username: String, + id: String, + query: StatusQuery, + photo: Option, +) -> Result { + let prefs = Prefs::from_cookies(jar, &state.config); + let is_discord = headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .is_some_and(|user_agent| user_agent.contains("Discordbot")); @@ -188,8 +257,12 @@ async fn status( helpers::enrich_conversation(&state, &mut conversation).await; + let focused_photo = photo.filter(|&idx| conversation.tweet.keep_only_photo(idx)); + let activity = + is_discord.then(|| embed::ActivityId::new(conversation.tweet.id, focused_photo)); + // Auto-translate for embeds (OG tags / ActivityPub show translated text) - if !discord_activity && !is_scroll && !has_cursor && conversation.tweet.is_translatable { + if !is_discord && !is_scroll && !has_cursor && conversation.tweet.is_translatable { let kagi = &state.config.config.kagi_token; let token = (!kagi.is_empty()).then_some(kagi.as_str()); if let Ok(tl) = get_cached_translation(&state, &conversation.tweet, token).await @@ -240,6 +313,9 @@ async fn status( } }; first_page.replies.bottom = None; + if let Some(idx) = focused_photo { + first_page.tweet.keep_only_photo(idx); + } return Ok(render_conversation( &first_page, false, @@ -248,7 +324,7 @@ async fn status( &prefs, &state.config, query.sort.as_deref(), - discord_activity, + activity, )); } @@ -270,6 +346,9 @@ async fn status( conversation.tweet = fresh.tweet; } } + if let Some(idx) = focused_photo { + conversation.tweet.keep_only_photo(idx); + } } Ok(render_conversation( @@ -280,7 +359,7 @@ async fn status( &prefs, &state.config, query.sort.as_deref(), - discord_activity, + activity, )) }, Err(Error::TweetNotFound(msg)) => { @@ -304,7 +383,7 @@ fn render_conversation( prefs: &Prefs, config: &Config, sort: Option<&str>, - discord_activity: bool, + activity: Option, ) -> Response { let tweet = &conversation.tweet; @@ -461,7 +540,7 @@ fn render_conversation( config, username, id, - discord_activity, + activity, ); Html(markup.into_string()).into_response() } @@ -532,6 +611,8 @@ async fn status_by_id( } let username = conversation.tweet.user.username.clone(); + let activity = + discord_activity.then(|| embed::ActivityId::new(conversation.tweet.id, None)); Ok(render_conversation( &conversation, query.cursor.is_some(), @@ -540,7 +621,7 @@ async fn status_by_id( &prefs, &state.config, query.sort.as_deref(), - discord_activity, + activity, )) }, Err(Error::TweetNotFound(msg)) => { diff --git a/src/types/tweet.rs b/src/types/tweet.rs index e71039b..a9f03f5 100644 --- a/src/types/tweet.rs +++ b/src/types/tweet.rs @@ -378,6 +378,16 @@ pub struct Translation { } impl Tweet { + /// Drop every photo but the one at `idx`, returning whether it existed. + pub fn keep_only_photo(&mut self, idx: usize) -> bool { + if idx >= self.photos.len() { + return false; + } + self.photos.swap(0, idx); + self.photos.truncate(1); + true + } + /// Check if this tweet has any media. pub const fn has_media(&self) -> bool { !self.photos.is_empty() @@ -412,6 +422,27 @@ mod tests { } } + #[test] + fn keep_only_photo_moves_the_chosen_one_first() { + let mut tweet = Tweet { + photos: vec![ + Photo { + url: "a".to_owned(), + ..Photo::default() + }, + Photo { + url: "b".to_owned(), + ..Photo::default() + }, + ], + ..Tweet::default() + }; + assert!(tweet.keep_only_photo(1)); + assert_eq!(tweet.photos.len(), 1); + assert_eq!(tweet.photos[0].url, "b"); + assert!(!tweet.keep_only_photo(3)); + } + #[test] fn cache_limit_uses_bitrate_and_duration() { assert!(!video(2_000_000, 10_000).exceeds_cloudflare_cache()); diff --git a/src/views/embed.rs b/src/views/embed.rs index 1039feb..d0c8831 100644 --- a/src/views/embed.rs +++ b/src/views/embed.rs @@ -573,9 +573,10 @@ pub fn render_status_page( config: &Config, username: &str, id: &str, - discord_activity: bool, + activity: Option, ) -> Markup { let url_prefix = config.url_prefix(); + let discord_activity = activity.is_some(); let oembed_url = build_oembed_url(tweet, config); let title = format!( @@ -616,11 +617,12 @@ pub fn render_status_page( meta property="og:title" content=(og_title); meta property="og:description" content=(description); meta property="og:site_name" content=(embed_provider_name(tweet, config)); - @if discord_activity { + @if let Some(activity) = activity { // Discord recognizes this as a Mastodon status, then requests - // `/api/v1/statuses/{id}` from the same host. + // `/api/v1/statuses/{id}` from the same host. A photo unfurl + // encodes the photo number into that id. link rel="alternate" - href=(format!("{url_prefix}/users/{username}/statuses/{id}")) + href=(format!("{url_prefix}/users/{username}/statuses/{activity}")) type="application/activity+json"; } diff --git a/src/views/embed_activity.rs b/src/views/embed_activity.rs index 360c5af..d9b9583 100644 --- a/src/views/embed_activity.rs +++ b/src/views/embed_activity.rs @@ -1,5 +1,70 @@ +use std::{ + fmt::{ + self, + Display, + Formatter, + }, + str::FromStr, +}; + use super::*; +use crate::error::Error; + +/// The status id Discord echoes back from the activity link. A `/photo/N` +/// link prefixes N to the tweet id zero-padded to 19 digits, a length no real +/// snowflake reaches, so Discord's follow-up request still names the photo. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ActivityId { + tweet: i64, + photo: Option, +} + +impl ActivityId { + pub fn new(tweet: i64, photo_idx: Option) -> Self { + let photo = photo_idx + .and_then(|idx| u8::try_from(idx + 1).ok()) + .filter(|number| *number <= 9); + Self { tweet, photo } + } + + pub const fn tweet(self) -> i64 { + self.tweet + } + + pub fn photo_idx(self) -> Option { + self.photo.map(|number| usize::from(number) - 1) + } +} + +impl Display for ActivityId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self.photo { + Some(number) => write!(f, "{number}{:019}", self.tweet), + None => write!(f, "{}", self.tweet), + } + } +} + +impl FromStr for ActivityId { + type Err = Error; + + fn from_str(raw: &str) -> Result { + let invalid = || Error::InvalidUrl("Invalid id".into()); + if !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + + let (photo, digits) = match (raw.len(), raw.split_at_checked(1)) { + (1..=19, _) => (None, raw), + (20, Some((number, rest))) if number != "0" => (number.parse::().ok(), rest), + _ => return Err(invalid()), + }; + let tweet = digits.parse::().map_err(|_| invalid())?; + Ok(Self { tweet, photo }) + } +} + /// Mastodon API v1-compatible status for Discord embed support. /// Discord uses `created_at` for the footer timestamp and `content` /// for rich text rendering (blockquotes for quoted tweets). @@ -868,7 +933,24 @@ pub fn build_activity_pub_with_reply( #[cfg(test)] mod tests { - use super::application_name; + use super::{ + ActivityId, + application_name, + }; + + #[test] + fn activity_id_keeps_the_photo_across_discord_followup() { + let id = ActivityId::new(2_101_716_895_007_203_333, Some(1)); + let encoded = id.to_string(); + assert_eq!(encoded, "22101716895007203333"); + let parsed: ActivityId = encoded.parse().unwrap(); + assert_eq!(parsed.tweet(), 2_101_716_895_007_203_333); + assert_eq!(parsed.photo_idx(), Some(1)); + + let plain = ActivityId::new(401, None); + assert_eq!(plain.to_string(), "401"); + assert_eq!("401".parse::().unwrap(), plain); + } #[test] fn application_uses_source_label() { diff --git a/src/views/embed_tests.rs b/src/views/embed_tests.rs index 23edebe..daabd9b 100644 --- a/src/views/embed_tests.rs +++ b/src/views/embed_tests.rs @@ -290,7 +290,7 @@ fn activity_payload_renders_live_broadcast_preview() { &test_config(), "tbpn", "406", - true, + Some(ActivityId::new(406, None)), ) .into_string(); let image_url = format!( @@ -660,7 +660,7 @@ fn status_page_uses_author_first_embed_metadata() { &test_config(), "G2CSGO", "400", - false, + None, ) .into_string(); @@ -700,7 +700,7 @@ fn discord_activity_page_defers_photo_media_to_mastodon_api() { &test_config(), "photos", "401", - true, + Some(ActivityId::new(401, None)), ) .into_string(); @@ -724,7 +724,7 @@ fn discord_activity_page_uses_avatar_for_text_only_status() { &test_config(), "plain", "402", - true, + Some(ActivityId::new(402, None)), ) .into_string(); @@ -750,7 +750,7 @@ fn generic_photo_metadata_includes_real_dimensions() { &test_config(), "photos", "403", - false, + None, ) .into_string(); diff --git a/src/views/tweet.rs b/src/views/tweet.rs index ac51ff3..34c1093 100644 --- a/src/views/tweet.rs +++ b/src/views/tweet.rs @@ -202,9 +202,9 @@ impl<'a> TweetRenderer<'a> { classes.push(extra_class); } match thread_ctx { - ThreadContext::Start => classes.push("thread-first"), - ThreadContext::Middle => classes.push("thread-middle"), - ThreadContext::End => classes.push("thread-last"), + ThreadContext::Start => classes.extend(["thread", "thread-first"]), + ThreadContext::Middle => classes.extend(["thread", "thread-middle"]), + ThreadContext::End => classes.extend(["thread", "thread-last"]), ThreadContext::None => {}, } let class = classes.join(" ");