small bsky embedder @ boobsky.app - kinda mid but works - mirror of git.fomx.gay/rooot/embedthing
at main 334 lines 11 kB view raw
1use std::fmt::{Display, Formatter}; 2 3use rocket::http::{ContentType, RawStr, Status}; 4use rocket::request::{FromRequest, Outcome}; 5use rocket::response::{Redirect, Responder}; 6use rocket::{Request, Response, response}; 7use serde::ser::SerializeMap; 8use serde::{Deserialize, Serialize}; 9use thiserror::Error; 10use url::Url; 11 12#[derive(Debug)] 13pub struct EmbedAuthor { 14 pub name: String, 15 pub handle: String, 16 pub profile_url: String, 17 pub avatar_url: Option<String>, 18} 19 20#[derive(Debug)] 21pub enum EmbedMedia { 22 Video(String), 23 Image(String), 24} 25 26// this contains the original source/url of the embed 27#[derive(Debug)] 28pub enum EmbedSource { 29 Bsky(String, String), 30} 31 32impl Display for EmbedSource { 33 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 34 match self { 35 EmbedSource::Bsky(name, post) => { 36 write!(f, "https://bsky.app/profile/{}/post/{}", name, post) 37 } 38 } 39 } 40} 41 42#[derive(Debug)] 43pub struct EmbedThingy { 44 pub author: Option<EmbedAuthor>, 45 pub text: Option<String>, 46 pub embeds: Vec<EmbedMedia>, 47 pub source: EmbedSource, 48} 49 50impl<'r, 'o: 'r> Responder<'r, 'o> for EmbedThingy { 51 fn respond_to(self, req: &'r Request<'_>) -> response::Result<'o> { 52 let mut html = String::new(); 53 html.push_str("<!DOCTYPE html>\n<html>\n<head>\n"); 54 html.push_str("<meta charset=\"UTF-8\">\n"); 55 56 // maybe in the future this will be useful IF i add support for other sites 57 58 let source_url = self.source.to_string(); 59 let color = match self.source { 60 EmbedSource::Bsky(_, _) => "#0085ff", 61 }; 62 63 html.push_str(&format!( 64 "<meta property=\"theme-color\" content=\"{}\">\n", 65 color 66 )); 67 68 // add canonical urls 69 html.push_str(&format!(r#"<link rel="canonical" href="{}">"#, source_url)); 70 html.push_str(&format!( 71 r#"<meta property="og:url" content="{}">"#, 72 source_url 73 )); 74 html.push('\n'); 75 76 // add some meta tags 77 // html.push_str(r#"<meta property="og:site_name" content="AAAAAAAAAAAA">"#); 78 // html.push('\n'); 79 80 if let Some(ref text) = self.text { 81 html.push_str(&format!( 82 r#"<meta property="og:description" content="{}">"#, 83 RawStr::new(text).html_escape() 84 )); 85 html.push('\n'); 86 } 87 88 // important: if there's no author, we will lack og:title 89 // discord will then, FOR VIDEOS ONLY, ignore everything and just embed the video 90 if let Some(author) = self.author { 91 let author_title = format!("{} (@{})", author.name, author.handle); 92 html.push_str(&format!( 93 r#"<meta property="og:title" content="{}">"#, 94 RawStr::new(&author_title).html_escape() 95 )); 96 html.push('\n'); 97 //html.push_str(&format!(r#"<meta property="" content="{}">"#, author.profile_url)); 98 } 99 100 if !self.embeds.is_empty() { 101 for embed in self.embeds { 102 match embed { 103 EmbedMedia::Image(url) => { 104 println!("hehe image ! {}", url); 105 html.push_str(&format!(r#"<meta property="og:image" content="{}">"#, url)); 106 html.push('\n'); 107 108 // make sure our image is big !! 109 html.push_str( 110 r#"<meta name="twitter:card" content="summary_large_image">"#, 111 ); 112 html.push('\n'); 113 } 114 EmbedMedia::Video(url) => { 115 html.push_str(&format!(r#"<meta property="og:video" content="{}">"#, url)); 116 html.push('\n'); 117 html.push_str(&format!( 118 r#"<meta property="og:video:url" content="{}">"#, 119 url 120 )); 121 html.push('\n'); 122 html.push_str(&format!( 123 r#"<meta property="og:video:secure_url" content="{}">"#, 124 url 125 )); 126 html.push('\n'); 127 128 // THIS IS NEEDED FOR DISCORD !! 129 // todo: maybe don't hardcode video/mp4 as content type for all videos? 130 html.push_str(r#"<meta property="og:video:type" content="video/mp4">"#); 131 html.push('\n'); 132 133 html.push_str(&format!( 134 r#"<meta property="twitter:player:stream" content="{}">"#, 135 url 136 )); 137 html.push('\n'); 138 139 html.push_str(r#"<meta property="twitter:card" content="player">"#); 140 html.push('\n'); 141 142 // push oembed - we only do this for videos bcs discord is stupid 143 let host = req.host().map_or_else( 144 || { 145 eprintln!( 146 "WARNING: couldn't get host from request - oembed won't work!" 147 ); 148 "".to_string() 149 }, 150 |host| { 151 let is_https = 152 req.headers().get_one("x-forwarded-proto") == Some("https"); 153 format!("{}://{}", if is_https { "https" } else { "http" }, host) 154 }, 155 ); 156 157 let mut oembed_url = 158 Url::parse(format!("{}/api/v1/oembed", host).as_str()).unwrap(); 159 if let Some(ref text) = self.text { 160 let text = if text.len() > 256 { 161 &text.chars().take(255).chain(['…']).collect::<String>() 162 } else { 163 text 164 }; 165 166 oembed_url.query_pairs_mut().append_pair("desc", text); 167 } else { 168 oembed_url.query_pairs_mut().append_pair("desc", "meow"); 169 } 170 171 html.push_str(&format!( 172 r#"<link rel="alternate" type="application/json+oembed" href="{}" />"#, 173 oembed_url.as_str() 174 )); 175 } 176 } 177 } 178 } 179 180 html.push_str("</head>\n<body>meow</body>\n</html>"); 181 Ok(Response::build() 182 .header(ContentType::HTML) 183 .status(Status::Ok) 184 .streamed_body(std::io::Cursor::new(html)) 185 .finalize()) 186 } 187} 188 189#[derive(Error, Debug)] 190#[error("{0}")] 191pub struct MissingElementError(pub String); 192 193#[derive(Error, Debug)] 194#[error(transparent)] 195pub enum GenericError { 196 #[error("Missing element or invalid selector: {0}")] 197 MissingElement(#[from] MissingElementError), 198 199 #[error("DID not found")] 200 DIDNotFound, 201 202 #[error("too tired to make an actuall error type")] 203 TooTired, 204 205 #[error("blocked by {0}")] 206 BlockedBy(String), 207 208 #[error("atrium error: {0}")] 209 Atrium(#[from] bsky_sdk::api::error::Error), 210 211 #[error("get post thread error: {0}")] 212 GetPostThread( 213 #[from] bsky_sdk::api::xrpc::Error<bsky_sdk::api::app::bsky::feed::get_post_thread::Error>, 214 ), 215 216 #[error("get blob error: {0}")] 217 GetBlob(#[from] bsky_sdk::api::xrpc::Error<bsky_sdk::api::com::atproto::sync::get_blob::Error>), 218 219 #[error("invalid cid")] 220 InvalidCID(#[from] ipld_core::cid::Error), 221 222 #[error("bsky sdk error")] 223 Bsky(#[from] bsky_sdk::Error), 224 225 #[error("reqwest error")] 226 Reqwest(#[from] reqwest::Error), 227} 228 229impl Serialize for GenericError { 230 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 231 let error_message = match self { 232 GenericError::MissingElement(e) => e.to_string(), 233 GenericError::Atrium(e) => e.to_string(), 234 GenericError::GetPostThread(e) => e.to_string(), 235 GenericError::DIDNotFound => "DID not found".to_string(), 236 GenericError::TooTired => self.to_string(), 237 GenericError::BlockedBy(_) => self.to_string(), 238 GenericError::GetBlob(e) => e.to_string(), 239 GenericError::InvalidCID(e) => e.to_string(), 240 GenericError::Bsky(e) => e.to_string(), 241 _ => self.to_string(), 242 }; 243 let mut map = serializer.serialize_map(Some(2))?; 244 map.serialize_entry("error", &format!("{}: {}", self, &error_message))?; 245 map.serialize_entry("status", &"error")?; 246 map.end() 247 } 248} 249 250impl<'r, 'o: 'r> Responder<'r, 'o> for GenericError { 251 fn respond_to(self, _req: &'r Request<'_>) -> response::Result<'o> { 252 Ok(Response::build() 253 .header(ContentType::JSON) 254 .status(Status::InternalServerError) 255 .streamed_body(std::io::Cursor::new(serde_json::to_string(&self).unwrap())) 256 .finalize()) 257 } 258} 259 260pub enum EmbedResponse { 261 Embed(EmbedThingy), 262 Redirect(String), 263} 264 265impl<'r, 'o: 'r> Responder<'r, 'o> for EmbedResponse { 266 fn respond_to(self, req: &'r Request<'_>) -> response::Result<'o> { 267 match self { 268 EmbedResponse::Embed(embed) => embed.respond_to(req), 269 EmbedResponse::Redirect(url) => Redirect::temporary(url).respond_to(req), 270 } 271 } 272} 273 274pub struct BotCheck(bool); 275 276impl BotCheck { 277 pub fn redirect(&self, src: &EmbedSource) -> Option<EmbedResponse> { 278 if self.0 { 279 Some(EmbedResponse::Redirect(src.to_string())) 280 } else { 281 None 282 } 283 } 284} 285 286fn is_bot(user_agent: &str) -> bool { 287 // if you know a bot not listed here that embeds content, pls open an issue!! 288 let agents = [ 289 "Discordbot", 290 "https://discordapp.com", 291 "Bluesky Cardyb", 292 "https://opengraph.io", 293 "Twitterbot", 294 "TwitterBot", 295 "FacebookBot", 296 "Conduwuit", 297 "WhatsApp", // this is also signal 298 // not a bot but used for debugging :3c 299 "curl/", 300 ]; 301 302 agents.iter().any(|agent| user_agent.contains(*agent)) 303} 304 305#[rocket::async_trait] 306impl<'r> FromRequest<'r> for BotCheck { 307 type Error = (); 308 309 async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { 310 if let Some(user_agent) = req.headers().get_one("user-agent") 311 && !is_bot(user_agent) 312 { 313 return Outcome::Success(Self(true)); 314 } 315 316 Outcome::Success(Self(false)) 317 } 318} 319 320#[derive(Serialize, Deserialize)] 321pub struct PlcService { 322 pub id: String, 323 #[serde(rename = "type")] 324 pub type_: String, 325 326 #[serde(rename = "serviceEndpoint")] 327 pub service_endpoint: String, 328} 329 330#[derive(Serialize, Deserialize)] 331pub struct DidPlcResponse { 332 pub id: String, 333 pub service: Vec<PlcService>, 334}