jevcrates.git / jev-ui / src / preview.rs
1//! What a pasted link unfurls as: Open Graph and Twitter card tags for a title, a description,
2//! a still picture and, optionally, a looping video. A site with a video gets the combination
3//! reported to make Discord animate it (MediaCMS discussion 1183; PeerTube issue 5040): an
4//! animated GIF as `og:image` shows only its first frame there, and Discord makes embeds only
5//! for files under about 8 MB. A site without one gets a large still card.
6//!
7//! `Preview::tags` refuses what a scraper would reject (a relative address, a video of odd
8//! size), so a bad card cannot be built; `missing` checks rendered HTML for the tags that
9//! matter, which is how a site's tests and checks read the live page.
10//!
11//! Moved here from whiskers' `site::preview` (2026-10-05), where it was written; lmjtfy's own
12//! tags in `view.rs` are the same job done by hand and are to be replaced by this.
13
14use std::fmt;
15
16/// Where the site is: `https://host` or, for local runs, `http://localhost[:port]`.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct Origin(String);
19
20#[derive(Debug, PartialEq, Eq)]
21pub enum Refused {
22    /// Not `https://host` (or `http://localhost`), or it has a path, query or trailing slash.
23    Origin(String),
24    /// A path that does not start with `/` or has spaces or a query.
25    Path(String),
26    /// A picture's query that is not unreserved or percent-encoded characters.
27    Query(String),
28    /// H.264 in yuv420p needs even dimensions; neither may be zero.
29    VideoSize(u32, u32),
30    /// A card smaller than this is not shown large.
31    ImageSize(u32, u32),
32    Empty(&'static str),
33    Long(&'static str, usize),
34}
35
36impl fmt::Display for Refused {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Refused::Origin(o) => write!(f, "origin {o:?} is not https://host (or http://localhost)"),
40            Refused::Path(p) => write!(f, "path {p:?} is not an absolute path without a query"),
41            Refused::Query(q) => write!(f, "query {q:?} is not unreserved or percent-encoded characters"),
42            Refused::VideoSize(w, h) => write!(f, "video {w}x{h} needs even, non-zero sides"),
43            Refused::ImageSize(w, h) => write!(f, "image {w}x{h} is too small for a large card"),
44            Refused::Empty(what) => write!(f, "{what} is empty"),
45            Refused::Long(what, n) => write!(f, "{what} is {n} characters, too long to show whole"),
46        }
47    }
48}
49
50impl Origin {
51    pub fn parse(text: &str) -> Result<Origin, Refused> {
52        let bad = || Refused::Origin(text.to_owned());
53        let (scheme, host) = text.split_once("://").ok_or_else(bad)?;
54        let local = host == "localhost" || host.starts_with("localhost:") || host == "127.0.0.1" || host.starts_with("127.0.0.1:");
55        let ok_scheme = scheme == "https" || (scheme == "http" && local);
56        let ok_host = !host.is_empty() && host.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b':');
57        if ok_scheme && ok_host { Ok(Origin(text.to_owned())) } else { Err(bad()) }
58    }
59
60    pub fn url(&self, path: &str) -> Result<String, Refused> {
61        if path.starts_with('/') && !path.contains(['?', '#', ' ']) { Ok(format!("{}{path}", self.0)) } else { Err(Refused::Path(path.to_owned())) }
62    }
63
64    /// `url`, with a query on it.
65    pub fn url_with_query(&self, path: &str, query: &str) -> Result<String, Refused> {
66        let safe = !query.is_empty() && query.bytes().all(|b| b.is_ascii_alphanumeric() || b"-._~%=&".contains(&b));
67        if !safe {
68            return Err(Refused::Query(query.to_owned()));
69        }
70        Ok(format!("{}?{query}", self.url(path)?))
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78pub struct Picture<'a> {
79    pub path: &'a str,
80    /// A query that names which picture, for a site that draws one per page
81    /// (lmjtfy's `/card.png?q=…`): `a=b&c=d` in unreserved or percent-encoded
82    /// characters only, so it needs no escaping to sit in an attribute.
83    pub query: Option<&'a str>,
84    pub width: u32,
85    pub height: u32,
86    pub alt: &'a str,
87}
88
89pub struct Video<'a> {
90    pub path: &'a str,
91    pub width: u32,
92    pub height: u32,
93}
94
95pub struct Preview<'a> {
96    pub origin: &'a Origin,
97    pub site_name: &'a str,
98    pub title: &'a str,
99    pub description: &'a str,
100    /// The page's own address, as a path.
101    pub page: &'a str,
102    pub picture: Picture<'a>,
103    /// `None` is a still card (`summary_large_image`); `Some` is a player card.
104    pub video: Option<Video<'a>>,
105    /// The bar down the side of a Discord embed.
106    pub theme_color: &'a str,
107}
108
109/// Whether a tag is keyed by `property` (Open Graph) or `name` (everything else).
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum Key {
112    Property,
113    Name,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct Tag {
118    pub key: Key,
119    pub name: &'static str,
120    pub content: String,
121}
122
123/// Open Graph titles are cut by the apps that show them; keep ours whole.
124const MAX_TITLE: usize = 70;
125const MAX_DESCRIPTION: usize = 300;
126
127impl Preview<'_> {
128    pub fn tags(&self) -> Result<Vec<Tag>, Refused> {
129        for (what, text, max) in [("title", self.title, MAX_TITLE), ("description", self.description, MAX_DESCRIPTION)] {
130            if text.trim().is_empty() {
131                return Err(Refused::Empty(what));
132            }
133            if text.chars().count() > max {
134                return Err(Refused::Long(what, text.chars().count()));
135            }
136        }
137        if let Some(video) = &self.video {
138            let (vw, vh) = (video.width, video.height);
139            if vw == 0 || vh == 0 || vw % 2 != 0 || vh % 2 != 0 {
140                return Err(Refused::VideoSize(vw, vh));
141            }
142        }
143        let (iw, ih) = (self.picture.width, self.picture.height);
144        // Twitter and Discord show a large card for at least 300 by 157.
145        if iw < 300 || ih < 157 {
146            return Err(Refused::ImageSize(iw, ih));
147        }
148        let page = self.origin.url(self.page)?;
149        let image = match self.picture.query {
150            Some(query) => self.origin.url_with_query(self.picture.path, query)?,
151            None => self.origin.url(self.picture.path)?,
152        };
153        let prop = |name, content: &str| Tag { key: Key::Property, name, content: content.to_owned() };
154        let named = |name, content: &str| Tag { key: Key::Name, name, content: content.to_owned() };
155        let mut tags = vec![
156            named("description", self.description),
157            named("theme-color", self.theme_color),
158            prop("og:type", "website"),
159            prop("og:site_name", self.site_name),
160            prop("og:url", &page),
161            prop("og:title", self.title),
162            prop("og:description", self.description),
163            prop("og:image", &image),
164            prop("og:image:type", "image/png"),
165            prop("og:image:width", &iw.to_string()),
166            prop("og:image:height", &ih.to_string()),
167            prop("og:image:alt", self.picture.alt),
168        ];
169        match &self.video {
170            Some(video) => {
171                let url = self.origin.url(video.path)?;
172                tags.extend([
173                    prop("og:video", &url),
174                    prop("og:video:url", &url),
175                    prop("og:video:secure_url", &url),
176                    prop("og:video:type", "video/mp4"),
177                    prop("og:video:width", &video.width.to_string()),
178                    prop("og:video:height", &video.height.to_string()),
179                    named("twitter:card", "player"),
180                    named("twitter:title", self.title),
181                    named("twitter:description", self.description),
182                    named("twitter:image", &image),
183                    named("twitter:player", &url),
184                    named("twitter:player:stream", &url),
185                    named("twitter:player:stream:content_type", "video/mp4"),
186                    named("twitter:player:width", &video.width.to_string()),
187                    named("twitter:player:height", &video.height.to_string()),
188                ]);
189            }
190            None => tags.extend([
191                // The picture is shown large, under the text.
192                named("twitter:card", "summary_large_image"),
193                named("twitter:title", self.title),
194                named("twitter:description", self.description),
195                named("twitter:image", &image),
196            ]),
197        }
198        Ok(tags)
199    }
200
201    /// The tags as HTML for the page's `<head>`, one per line.
202    pub fn html(&self) -> Result<String, Refused> {
203        Ok(self.tags()?.iter().map(Tag::html).collect::<Vec<_>>().join("\n"))
204    }
205}
206
207impl Tag {
208    pub fn html(&self) -> String {
209        let key = match self.key {
210            Key::Property => "property",
211            Key::Name => "name",
212        };
213        format!("<meta {key}=\"{}\" content=\"{}\">", self.name, escape(&self.content))
214    }
215}
216
217fn escape(text: &str) -> String {
218    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
219}
220
221/// Which card a page is meant to unfurl as.
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
223pub enum Kind {
224    Still,
225    Video,
226}
227
228/// Every tag a scraper needs for a large still card.
229pub const REQUIRED_STILL: [&str; 7] = ["og:title", "og:description", "og:image", "og:image:type", "og:image:width", "og:image:height", "twitter:image"];
230
231/// What a video card needs besides the still's.
232pub const REQUIRED_VIDEO_ADDS: [&str; 10] = [
233    "og:video",
234    "og:video:type",
235    "og:video:width",
236    "og:video:height",
237    "twitter:player",
238    "twitter:player:stream",
239    "twitter:player:stream:content_type",
240    "twitter:player:width",
241    "twitter:player:height",
242    "twitter:card",
243];
244
245/// Every tag a scraper needs for `kind`, by name (`twitter:card` is checked for its value too).
246pub fn required(kind: Kind) -> Vec<&'static str> {
247    let mut names = REQUIRED_STILL.to_vec();
248    names.push("twitter:card");
249    if kind == Kind::Video {
250        names.extend(REQUIRED_VIDEO_ADDS.iter().filter(|name| **name != "twitter:card"));
251    }
252    names
253}
254
255/// The `(name, content)` of every `<meta property|name=… content=…>` in some HTML, unescaped.
256/// Reads the shape this crate writes (and maud's), not arbitrary HTML.
257pub fn read_meta(html: &str) -> Vec<(String, String)> {
258    let mut found = Vec::new();
259    let mut rest = html;
260    while let Some(at) = rest.find("<meta ") {
261        let tag = &rest[at..];
262        let end = tag.find('>').map_or(tag.len(), |i| i + 1);
263        let tag_text = &tag[..end];
264        if let (Some(name), Some(content)) = (attr(tag_text, "property").or_else(|| attr(tag_text, "name")), attr(tag_text, "content")) {
265            found.push((name, content));
266        }
267        rest = &rest[end..];
268    }
269    found
270}
271
272fn attr(tag: &str, name: &str) -> Option<String> {
273    let start = tag.find(&format!(" {name}=\""))? + name.len() + 3;
274    let len = tag[start..].find('"')?;
275    Some(tag[start..start + len].replace("&quot;", "\"").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&"))
276}
277
278/// What is wrong with a page's tags: a required one absent or empty, an address that is not
279/// absolute, or a card of the wrong kind. Empty means a scraper will be satisfied.
280pub fn missing(html: &str, kind: Kind) -> Vec<String> {
281    let meta = read_meta(html);
282    let get = |name: &str| meta.iter().find(|(n, _)| n == name).map(|(_, c)| c.as_str());
283    let mut problems = Vec::new();
284    for name in required(kind) {
285        match get(name) {
286            None => problems.push(format!("{name} is missing")),
287            Some("") => problems.push(format!("{name} is empty")),
288            Some(_) => {}
289        }
290    }
291    for name in ["og:image", "og:video", "twitter:image", "twitter:player", "twitter:player:stream"] {
292        if let Some(url) = get(name)
293            && !(url.starts_with("https://") || url.starts_with("http://"))
294        {
295            problems.push(format!("{name} is not an absolute address: {url}"));
296        }
297    }
298    let card = match kind {
299        Kind::Still => "summary_large_image",
300        Kind::Video => "player",
301    };
302    if get("twitter:card").is_some_and(|got| got != card) {
303        problems.push(format!("twitter:card is not {card}"));
304    }
305    if get("og:video:type").is_some_and(|kind| kind != "video/mp4") {
306        problems.push("og:video:type is not video/mp4".to_owned());
307    }
308    problems
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn origin() -> Origin {
316        Origin::parse("https://whiskers.example").unwrap()
317    }
318
319    fn preview(origin: &Origin) -> Preview<'_> {
320        Preview {
321            origin,
322            site_name: "Whiskers",
323            title: "Whiskers, a talking cat",
324            description: "A cat for a young child & her \"grown-ups\".",
325            page: "/",
326            picture: Picture { path: "/preview/card.png", query: None, width: 1200, height: 630, alt: "The cat" },
327            video: Some(Video { path: "/preview/loop.mp4", width: 1200, height: 630 }),
328            theme_color: "#f386a1",
329        }
330    }
331
332    fn still(origin: &Origin) -> Preview<'_> {
333        Preview { video: None, ..preview(origin) }
334    }
335
336    #[test]
337    fn the_page_carries_every_tag_a_scraper_needs() {
338        let origin = origin();
339        let html = preview(&origin).html().unwrap();
340        assert_eq!(missing(&html, Kind::Video), Vec::<String>::new());
341        let meta = read_meta(&html);
342        let get = |name| meta.iter().find(|(n, _)| n == name).unwrap().1.clone();
343        assert_eq!(get("og:image"), "https://whiskers.example/preview/card.png");
344        assert_eq!(get("og:video"), "https://whiskers.example/preview/loop.mp4");
345        assert_eq!(get("twitter:player"), get("og:video"));
346        assert_eq!(get("twitter:player:stream"), get("og:video"));
347        assert_eq!(get("twitter:card"), "player");
348        assert_eq!(get("og:video:type"), "video/mp4");
349        assert_eq!((get("og:video:width"), get("og:video:height")), ("1200".into(), "630".into()));
350    }
351
352    #[test]
353    fn a_site_without_a_video_gets_a_large_still_and_no_video_tags() {
354        let origin = origin();
355        let html = still(&origin).html().unwrap();
356        assert_eq!(missing(&html, Kind::Still), Vec::<String>::new());
357        let meta = read_meta(&html);
358        assert!(meta.contains(&("twitter:card".to_owned(), "summary_large_image".to_owned())));
359        assert!(meta.iter().all(|(name, _)| !name.contains("video") && !name.contains("player")));
360        // The same page, read as a video card, is what is wrong with it.
361        assert!(missing(&html, Kind::Video).contains(&"og:video is missing".to_owned()));
362    }
363
364    #[test]
365    fn a_picture_that_is_drawn_per_page_may_carry_a_safe_query() {
366        let origin = origin();
367        let mut p = still(&origin);
368        p.picture.query = Some("q=is%20it%20ok&a=1f2e3d4c");
369        let meta = read_meta(&p.html().unwrap());
370        let get = |name| meta.iter().find(|(n, _)| n == name).unwrap().1.clone();
371        assert_eq!(get("og:image"), "https://whiskers.example/preview/card.png?q=is%20it%20ok&a=1f2e3d4c");
372        assert_eq!(get("twitter:image"), get("og:image"));
373        for bad in ["q=a b", "q=\"", "q=<", "a#b", ""] {
374            let mut p = still(&origin);
375            p.picture.query = Some(bad);
376            assert!(matches!(p.tags(), Err(Refused::Query(_))), "{bad:?}");
377        }
378    }
379
380    #[test]
381    fn text_is_escaped_and_reads_back() {
382        let origin = origin();
383        let html = preview(&origin).html().unwrap();
384        assert!(html.contains("A cat for a young child &amp; her &quot;grown-ups&quot;."));
385        let meta = read_meta(&html);
386        assert!(meta.contains(&("og:description".to_owned(), "A cat for a young child & her \"grown-ups\".".to_owned())));
387    }
388
389    #[test]
390    fn no_tag_is_written_twice() {
391        let origin = origin();
392        for p in [preview(&origin), still(&origin)] {
393            let tags = p.tags().unwrap();
394            let mut names: Vec<_> = tags.iter().map(|t| t.name).collect();
395            names.sort_unstable();
396            let n = names.len();
397            names.dedup();
398            assert_eq!(names.len(), n);
399        }
400    }
401
402    #[test]
403    fn what_a_scraper_would_reject_cannot_be_built() {
404        let origin = origin();
405        let mut p = preview(&origin);
406        p.video.as_mut().unwrap().width = 1201;
407        assert_eq!(p.tags().unwrap_err(), Refused::VideoSize(1201, 630));
408        let mut p = preview(&origin);
409        p.video.as_mut().unwrap().height = 0;
410        assert!(matches!(p.tags(), Err(Refused::VideoSize(..))));
411        let mut p = preview(&origin);
412        p.picture.width = 100;
413        assert!(matches!(p.tags(), Err(Refused::ImageSize(..))));
414        let mut p = preview(&origin);
415        p.picture.path = "preview/card.png";
416        assert!(matches!(p.tags(), Err(Refused::Path(_))));
417        let mut p = preview(&origin);
418        p.video.as_mut().unwrap().path = "/loop.mp4?x=1";
419        assert!(matches!(p.tags(), Err(Refused::Path(_))));
420        let mut p = preview(&origin);
421        p.title = "";
422        assert_eq!(p.tags().unwrap_err(), Refused::Empty("title"));
423        let long = "x".repeat(71);
424        let mut p = preview(&origin);
425        p.title = &long;
426        assert!(matches!(p.tags(), Err(Refused::Long("title", 71))));
427    }
428
429    #[test]
430    fn origins_are_https_hosts_or_localhost() {
431        for ok in ["https://whiskers.lmjtfy.fun", "http://localhost:8787", "http://127.0.0.1:8787", "https://a-b.c"] {
432            assert!(Origin::parse(ok).is_ok(), "{ok}");
433        }
434        for bad in ["http://whiskers.lmjtfy.fun", "https://x/", "https://x/path", "whiskers.lmjtfy.fun", "https://", "https://x y", "javascript://x", "https://x?a=1"] {
435            assert!(Origin::parse(bad).is_err(), "{bad}");
436        }
437    }
438
439    #[test]
440    fn missing_names_each_problem() {
441        let problems = missing(
442            "<meta property=\"og:title\" content=\"x\"><meta name=\"twitter:card\" content=\"summary\"><meta property=\"og:image\" content=\"/card.png\">",
443            Kind::Video,
444        );
445        assert!(problems.contains(&"og:video is missing".to_owned()));
446        assert!(problems.contains(&"twitter:card is not player".to_owned()));
447        assert!(problems.iter().any(|p| p.starts_with("og:image is not an absolute address")));
448    }
449
450    #[test]
451    fn the_required_names_are_what_the_tags_write() {
452        let origin = origin();
453        for (p, kind) in [(preview(&origin), Kind::Video), (still(&origin), Kind::Still)] {
454            let written: Vec<_> = p.tags().unwrap().iter().map(|t| t.name).collect();
455            for name in required(kind) {
456                assert!(written.contains(&name), "{name}");
457            }
458        }
459    }
460}