1use std::collections::HashMap;
2
3use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
4use url::Url;
5
6use serde::{Deserialize, Serialize};
7
8#[cfg(feature = "ts_rs")]
9use ts_rs::TS;
10
11use crate::prelude::*;
12
13#[derive(Deserialize, Serialize)]
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15pub struct OEmbedResponse {
16 pub author_name: String,
17 pub author_url: String,
18 pub html: String,
19 pub provider_name: String,
20 pub provider_url: String,
21 pub title: String,
22 pub version: String,
23}
24
25#[derive(Deserialize, Debug)]
26pub struct OEmbedRequest {
27 pub url: String,
28}
29
30pub fn url_to_oembed_endpoint(url: String, base_url: Option<String>) -> UtilResult<Url> {
32 let parsed_url = Url::parse(url.as_str())?;
33 if let Some(host) = parsed_url.host_str() {
34 if host.ends_with("youtu.be") || host.ends_with("youtube.com") {
35 return oembed_url_builder(
36 "https://www.youtube.com/oembed",
37 &format!("url={}&format=json&maxwidth=780&maxheight=440", url),
38 );
39 }
40 if host.ends_with("twitter.com") {
41 return oembed_url_builder(
42 "https://publish.twitter.com/oembed",
43 &format!("url={}&maxwidth=780&maxheight=440", url),
44 );
45 }
46 if host.ends_with("soundcloud.com") {
47 return oembed_url_builder(
48 "https://soundcloud.com/oembed",
49 &format!("url={}&format=json&maxwidth=780&maxheight=440", url),
50 );
51 }
52 if host.ends_with("open.spotify.com") || host.ends_with("play.spotify.com") {
53 return oembed_url_builder(
54 "https://embed.spotify.com/oembed",
55 &format!("url={}&format=json&height=335&width=780", url),
56 );
57 }
58 if host.ends_with("flickr.com") || host.ends_with("flic.kr") {
59 return oembed_url_builder(
60 "https://www.flickr.com/services/oembed",
61 &format!("url={}&format=json&maxwidth=780&maxheight=780", url),
62 );
63 }
64 if host.ends_with("vimeo.com") {
65 let base = base_url.as_ref().ok_or_else(|| {
66 UtilError::new(
67 UtilErrorType::Other,
68 "base_url is required for vimeo oembed".to_string(),
69 None,
70 )
71 })?;
72 return oembed_url_builder(
73 &format!("{}/api/v0/cms/gutenberg/oembed/vimeo", base),
74 &format!(
75 "url={}",
76 utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
77 ),
78 );
79 }
80 if host.ends_with("menti.com") || host.ends_with("mentimeter.com") {
81 let base = base_url.as_ref().ok_or_else(|| {
82 UtilError::new(
83 UtilErrorType::Other,
84 "base_url is required for mentimeter oembed".to_string(),
85 None,
86 )
87 })?;
88 return oembed_url_builder(
89 &format!("{}/api/v0/cms/gutenberg/oembed/mentimeter", base),
90 &format!(
91 "url={}",
92 utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
93 ),
94 );
95 }
96 if host.ends_with("thinglink.com") {
97 let base = base_url.as_ref().ok_or_else(|| {
98 UtilError::new(
99 UtilErrorType::Other,
100 "base_url is required for thinglink oembed".to_string(),
101 None,
102 )
103 })?;
104 return oembed_url_builder(
105 &format!("{}/api/v0/cms/gutenberg/oembed/thinglink", base),
106 &format!(
107 "url={}",
108 utf8_percent_encode(url.as_str(), NON_ALPHANUMERIC)
109 ),
110 );
111 }
112 if host.ends_with("imgur.com") {
113 return oembed_url_builder(
114 "https://api.imgur.com/oembed",
115 &format!("url={}&maxwidth=780", url),
116 );
117 }
118 if host.ends_with("reddit.com") {
119 return oembed_url_builder(
120 "https://www.reddit.com/oembed",
121 &format!("url={}&format=json", url),
122 );
123 }
124 if host.ends_with("slideshare.net") {
125 return oembed_url_builder(
126 "https://www.slideshare.net/api/oembed/2",
127 &format!("url={}&format=json", url),
128 );
129 }
130 if host.ends_with("ted.com") {
131 return oembed_url_builder(
132 "https://www.ted.com/services/v1/oembed.json",
133 &format!("url={}", url),
134 );
135 }
136 if host.ends_with("tumblr.com") {
137 return oembed_url_builder(
139 "https://www.tumblr.com/oembed/1.0",
140 &format!("url={}&format=json", url),
141 );
142 }
143 Err(UtilError::new(
144 UtilErrorType::Other,
145 "Link not supported for embedding.".to_string(),
146 None,
147 ))
148 } else {
149 Err(UtilError::new(
150 UtilErrorType::Other,
151 "Failed to parse host from URL.".to_string(),
152 None,
153 ))
154 }
155}
156
157pub fn mentimeter_oembed_response_builder(
158 url: String,
159 base_url: String,
160) -> UtilResult<OEmbedResponse> {
161 let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
162 UtilError::new(
163 UtilErrorType::UrlParse,
164 "Failed to parse url".to_string(),
165 Some(e.into()),
166 )
167 })?;
168 let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
170 parsed_url.set_query(None);
172 let decoded_title = percent_decode_str(
173 params
174 .get("title")
175 .unwrap_or(&"Mentimeter%20embed".to_string()),
176 )
177 .decode_utf8()
178 .map_err(|_| {
179 UtilError::new(
180 UtilErrorType::Other,
181 "Failed to decode title parameter as UTF-8 for mentimeter embed".to_string(),
182 None,
183 )
184 })?
185 .to_string();
186
187 let response = OEmbedResponse {
188 author_name: "Mooc.fi".to_string(),
189 author_url: base_url,
190 html: format!(
191 "<iframe src={} style='width: 99%;' height={:?} title={:?}></iframe>",
192 parsed_url,
193 params.get("height").unwrap_or(&"500".to_string()),
194 decoded_title
195 ),
196 provider_name: "mentimeter".to_string(),
197 provider_url: parsed_url
198 .host_str()
199 .unwrap_or("https://www.mentimeter.com")
200 .to_string(),
201 title: decoded_title,
202 version: "1.0".to_string(),
203 };
204 Ok(response)
205}
206
207pub fn thinglink_oembed_response_builder(
208 url: String,
209 base_url: String,
210) -> UtilResult<OEmbedResponse> {
211 let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
212 UtilError::new(
213 UtilErrorType::UrlParse,
214 "Failed to parse url".to_string(),
215 Some(e.into()),
216 )
217 })?;
218 let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
220 parsed_url.set_query(None);
222 let decoded_title = percent_decode_str(
223 params
224 .get("title")
225 .unwrap_or(&"Thinlink%20embed".to_string()),
226 )
227 .decode_utf8()
228 .map_err(|_| {
229 UtilError::new(
230 UtilErrorType::Other,
231 "Failed to decode title parameter as UTF-8 for thinglink embed".to_string(),
232 None,
233 )
234 })?
235 .to_string();
236
237 let response = OEmbedResponse {
238 author_name: "Mooc.fi".to_string(),
239 author_url: base_url,
240 html: format!(
241 "<iframe sandbox=\"allow-scripts allow-same-origin\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
242 parsed_url,
243 params.get("height").unwrap_or(&"500".to_string()),
244 decoded_title
245 ),
246 provider_name: "thinglink".to_string(),
247 provider_url: parsed_url
248 .host_str()
249 .unwrap_or("https://www.thinglink.com/")
250 .to_string(),
251 title: decoded_title,
252 version: "1.0".to_string(),
253 };
254 Ok(response)
255}
256
257pub fn vimeo_oembed_response_builder(url: String, base_url: String) -> UtilResult<OEmbedResponse> {
258 let mut parsed_url = Url::parse(url.as_str()).map_err(|e| {
259 UtilError::new(
260 UtilErrorType::UrlParse,
261 "Failed to parse url".to_string(),
262 Some(e.into()),
263 )
264 })?;
265 let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
267 parsed_url.set_query(None);
269 let decoded_title =
270 percent_decode_str(params.get("title").unwrap_or(&"Vimeo%20embed".to_string()))
271 .decode_utf8()
272 .map_err(|_| {
273 UtilError::new(
274 UtilErrorType::Other,
275 "Failed to decode title parameter as UTF-8 for vimeo embed".to_string(),
276 None,
277 )
278 })?
279 .to_string();
280
281 let path = parsed_url.path();
282 let video_id = extract_numerical_id(path).ok_or_else(|| {
283 UtilError::new(
284 UtilErrorType::Other,
285 "Could not find video id from url".to_string(),
286 None,
287 )
288 })?;
289
290 let mut iframe_url = Url::parse("https://player.vimeo.com").map_err(|e| {
291 UtilError::new(
292 UtilErrorType::UrlParse,
293 "Failed to parse url".to_string(),
294 Some(e.into()),
295 )
296 })?;
297 iframe_url.set_path(&format!("/video/{}", video_id));
298 iframe_url.set_query(Some("app_id=122963"));
299
300 let response = OEmbedResponse {
301 author_name: "Mooc.fi".to_string(),
302 author_url: base_url,
303 html: format!(
304 "<iframe sandbox=\"allow-scripts allow-same-origin\" frameborder=\"0\" src=\"{}\" style=\"width: 99%;\" height={:?} title=\"{:?}\"></iframe>",
305 iframe_url,
306 params.get("height").unwrap_or(&"500".to_string()),
307 decoded_title
308 ),
309 provider_name: "vimeo".to_string(),
310 provider_url: parsed_url
311 .host_str()
312 .unwrap_or("https://www.vimeo.com/")
313 .to_string(),
314 title: decoded_title,
315 version: "1.0".to_string(),
316 };
317 Ok(response)
318}
319
320fn oembed_url_builder(url: &str, query_params: &str) -> UtilResult<Url> {
321 let mut endpoint_url = Url::parse(url)?;
322 endpoint_url.set_query(Some(query_params));
323 Ok(endpoint_url)
324}
325
326fn extract_numerical_id(path: &str) -> Option<String> {
327 path.split('/')
328 .map(|o| o.trim())
329 .find(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()))
330 .map(|s| s.to_string())
331}
332
333#[cfg(test)]
334mod tests {
335 use url::Url;
336
337 use super::*;
338 #[test]
339 fn works_with_youtu_be() {
340 assert_eq!(
341 url_to_oembed_endpoint("https://youtu.be/dQw4w9WgXcQ".to_string(), None).unwrap(),
342 Url::parse(
343 "https://www.youtube.com/oembed?url=https://youtu.be/dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440"
344 )
345 .unwrap()
346 )
347 }
348 #[test]
349 fn works_with_youtube_com() {
350 assert_eq!(
351 url_to_oembed_endpoint("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(), None)
352 .unwrap(),
353 Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&format=json&maxwidth=780&maxheight=440").unwrap()
354 )
355 }
356 #[test]
357 fn works_with_youtube_com_playlist() {
358 assert_eq!(
359 url_to_oembed_endpoint(
360 "https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1".to_string(), None
361 )
362 .unwrap(),
363 Url::parse("https://www.youtube.com/oembed?url=https://www.youtube.com/playlist?list=gYLqDMMh1GEbHf-Q1&format=json&maxwidth=780&maxheight=440").unwrap()
364 )
365 }
366 #[test]
367 fn works_with_open_spotify_com() {
368 assert_eq!(url_to_oembed_endpoint(
369 "http://open.spotify.com/track/298gs9ATwKlQR".to_string(), None
370 )
371 .unwrap(),
372 Url::parse("https://embed.spotify.com/oembed?url=http://open.spotify.com/track/298gs9ATwKlQR&format=json&height=335&width=780").unwrap())
373 }
374 #[test]
375 fn works_with_flic_kr_com() {
376 assert_eq!(
377 url_to_oembed_endpoint("https://flic.kr/p/2jJ".to_string(), None).unwrap(),
378 Url::parse(
379 "https://www.flickr.com/services/oembed?url=https://flic.kr/p/2jJ&format=json&maxwidth=780&maxheight=780"
380 ).unwrap()
381 )
382 }
383
384 #[test]
385 fn works_with_thinglink() {
386 assert_eq!(
387 url_to_oembed_endpoint(
388 "https://www.thinglink.com/card/1205257932048957445".to_string(),
389 Some("http://project-331.local".to_string())
390 )
391 .unwrap()
392 .to_string(),
393 "http://project-331.local/api/v0/cms/gutenberg/oembed/thinglink?url=https%3A%2F%2Fwww%2Ethinglink%2Ecom%2Fcard%2F1205257932048957445"
394 )
395 }
396
397 #[test]
398 fn works_with_vimeo() {
399 assert_eq!(
400 url_to_oembed_endpoint(
401 "https://vimeo.com/275255674".to_string(),
402 Some("http://project-331.local".to_string())
403 )
404 .unwrap()
405 .to_string(),
406 "http://project-331.local/api/v0/cms/gutenberg/oembed/vimeo?url=https%3A%2F%2Fvimeo%2Ecom%2F275255674"
407 )
408 }
409}