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