diff --git a/lib/api/src/channel/rest_embed.rs b/lib/api/src/channel/rest_embed.rs new file mode 100644 index 0000000..00714b2 --- /dev/null +++ b/lib/api/src/channel/rest_embed.rs @@ -0,0 +1,91 @@ +use std::time::Duration; + +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use crate::channel::ChannelBus; + +const CACHE_KEY_PREFIX: &str = "oembed:twitter:"; +const CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); + +#[derive(Debug, Deserialize)] +pub struct OEmbedQuery { + pub url: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OEmbedResponse { + pub author_name: String, + pub author_url: String, + pub provider_name: String, + pub html: String, + pub width: Option, + pub height: Option, +} + +/// Proxy endpoint for fetching oEmbed data from Twitter/X. +/// Responses are cached via AppCache for 30 days (tweets are immutable). +pub async fn twitter_oembed( + bus: web::Data, + query: web::Query, +) -> Result { + let tweet_url = &query.url; + + // Validate it's actually a Twitter/X URL + if !tweet_url.starts_with("https://twitter.com/") + && !tweet_url.starts_with("https://x.com/") + && !tweet_url.starts_with("http://twitter.com/") + && !tweet_url.starts_with("http://x.com/") + { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": "URL must be a Twitter/X link" + }))); + } + + let cache = &bus.inner.cache; + let cache_key = format!("{CACHE_KEY_PREFIX}{tweet_url}"); + + // Check cache first + if let Ok(Some(cached)) = cache.get::(&cache_key).await { + return Ok(HttpResponse::Ok().json(cached)); + } + + let data = match fetch_oembed(tweet_url).await { + Ok(data) => data, + Err(err) => { + return Ok(HttpResponse::InternalServerError() + .json(serde_json::json!({ "error": err }))); + } + }; + + // Store in cache with 30-day TTL + let _ = cache.set_with_ttl(&cache_key, &data, CACHE_TTL).await; + + Ok(HttpResponse::Ok().json(data)) +} + +async fn fetch_oembed(tweet_url: &str) -> Result { + let oembed_url = format!( + "https://publish.twitter.com/oembed?url={}&omit_script=true&dnt=true", + urlencoding::encode(tweet_url) + ); + + let client = reqwest::Client::new(); + let resp = client + .get(&oembed_url) + .header("User-Agent", "GitDataAI/1.0") + .send() + .await + .map_err(|e| format!("Failed to fetch from Twitter: {e}"))?; + + if !resp.status().is_success() { + return Err(format!( + "Twitter returned status {}", + resp.status().as_u16() + )); + } + + resp.json::() + .await + .map_err(|e| format!("Failed to parse oEmbed response: {e}")) +}