small bsky embedder @ boobsky.app - kinda mid but works - mirror of git.fomx.gay/rooot/embedthing
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}