headless_lms_utils/
url_to_oembed_endpoint.rs

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
27// https://github.com/WordPress/wordpress-develop/blob/master/src/wp-includes/class-wp-oembed.php
28pub 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            // Old tumblr api, v2 is latest, but WP uses 1.0
135            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    // Get the height and title params
166    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
167    // We want to remove the query params so that the iframe src url doesn't have them
168    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    // Get the height and title params
216    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
217    // We want to remove the query params so that the iframe src url doesn't have them
218    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    // Get the height and title params
263    let params: HashMap<_, _> = parsed_url.query_pairs().into_owned().collect();
264    // We want to remove the query params so that the iframe src url doesn't have them
265    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}