lmjtfy.git / apps / lmjtfy / src / view / releases.rs

A repository's release files as a page: /<name>/latest/ (the latest release) and /<name>/releases/ (the recent ones), each file with its size and a link to download it. Pure: releases in, markup out. Every word from GitHub (tags, titles, file names) is untrusted and reaches the page only through maud's escaping; a link is made only from a name release::segment accepts, so what is not a route is shown and not linked.

8use maud::{DOCTYPE, Markup, PreEscaped, html};
10use super::code::{Project, preview};
11use super::{JS, head, nav};
12use crate::host::Host;
13use crate::release::{Release, human, kind_of, segment};

Where a file is downloaded from. On the latest page a file is linked by its kind, which does not change with the version, when that kind is one file's and a route; otherwise (and on the page of many) by its exact name.

18fn link(bare: &str, release: &Release, name: &str, stable: bool) -> Option<String> {
19    if stable {
20        let kind = kind_of(name, &release.tag);
21        let same = release.assets.iter().filter(|asset| kind_of(&asset.name, &release.tag) == kind).count();
22        if same == 1 && segment(&kind) {
23            return Some(format!("/{bare}/latest/{kind}"));
24        }
25    }
26    (segment(&release.tag) && segment(name)).then(|| format!("/{bare}/releases/{}/{name}", release.tag))
27}

many is the page of recent releases; otherwise it is the latest one.

30pub fn page(origin: &str, bare: &str, project: Project, many: bool, releases: &[Release]) -> Markup {
31    let title = if many { "Releases" } else { "Latest release" };
32    let about = format!("The release files of {bare}, to download without a GitHub account.");
33    html! {
34        (DOCTYPE)
35        html lang="en" {
36            (head(preview(origin, project.served, &format!("LMJTFY · {bare} {}", title.to_lowercase()), &about, project.branch)))
37            body {
38                div .sheet {
39                    div .wrap {
40                        (nav(Host::Code))
41                        div .headline {
42                            p .eyebrow { "download ............. " (bare) }
43                            h1 { (title) }
44                        }
45                        div .stack {
46                            @if releases.is_empty() {
47                                p .meta { "No release yet." }
48                            }
49                            @for release in releases {
50                                (card(bare, release, !many))
51                            }
52                            p .meta {
53                                a href={ "/" (project.served) } { "Browse the code" }
54                                " · "
55                                @if many {
56                                    a href={ "/" (bare) "/latest/" } { "Latest release" }
57                                } @else {
58                                    a href={ "/" (bare) "/releases/" } { "All releases" }
59                                }
60                            }
61                        }
62                    }
63                }
64                script { (PreEscaped(JS)) }
65            }
66        }
67    }
68}
70fn card(bare: &str, release: &Release, stable: bool) -> Markup {
71    html! {
72        div .win {
73            div .bar-title {
74                span { (release.tag) }
75                span .meta {
76                    (release.date())
77                    @if release.prerelease { " · pre-release" }
78                }
79            }
80            div .body {
81                @if let Some(name) = release.name.as_deref().filter(|name| !name.is_empty() && *name != release.tag) {
82                    p { (name) }
83                }
84                @if release.assets.is_empty() {
85                    p .meta { "No files." }
86                }
87                @for asset in &release.assets {
88                    p {
89                        @match link(bare, release, &asset.name, stable) {
90                            Some(href) => a href=(href) download=(asset.name) { (asset.name) },
91                            None => span { (asset.name) },
92                        }
93                        " " span .meta { (human(asset.size)) }
94                    }
95                }
96            }
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::clone::Repo;
105
106    fn release(tag: &str, names: &[(&str, u64)]) -> Release {
107        let assets: Vec<_> = names.iter().enumerate().map(|(id, (name, size))| format!(r#"{{"id":{id},"name":{name:?},"size":{size},"state":"uploaded"}}"#)).collect();
108        Release::parse(&format!(r#"{{"tag_name":{tag:?},"name":null,"published_at":"2026-10-05T00:00:00Z","assets":[{}]}}"#, assets.join(","))).unwrap()
109    }
110
111    #[test]
112    fn the_latest_page_links_each_file_by_its_kind() {
113        let r = release("2026.10.5", &[("whiskers-2026.10.5-arm64.apk", 80_400_000), ("SHA256SUMS", 300)]);
114        let html = page("https://code.x.test", "whiskers", Repo::Whiskers.project(), false, &[r]).into_string();
115        assert!(html.contains(r#"href="/whiskers/latest/arm64.apk""#), "{html}");
116        assert!(html.contains(r#"href="/whiskers/latest/SHA256SUMS""#), "{html}");
117        assert!(html.contains("80.4 MB"), "{html}");
118        assert!(html.contains("2026-10-05"), "{html}");
119        assert!(html.contains(r#"href="/whiskers/releases/""#), "{html}");
120    }
121
122    #[test]
123    fn the_page_of_many_links_exact_names_and_an_ambiguous_kind_is_too() {
124        let r = release("1.0", &[("a-1.0-x.zip", 1), ("b-1.0-x.zip", 2)]);
125        let html = page("https://code.x.test", "whiskers", Repo::Whiskers.project(), true, &[r.clone()]).into_string();
126        assert!(html.contains(r#"href="/whiskers/releases/1.0/a-1.0-x.zip""#), "{html}");
127        let html = page("https://code.x.test", "whiskers", Repo::Whiskers.project(), false, &[r]).into_string();
128        assert!(!html.contains("/latest/x.zip"), "{html}");
129        assert!(html.contains(r#"href="/whiskers/releases/1.0/b-1.0-x.zip""#), "{html}");
130    }
131
132    #[test]
133    fn github_words_are_escaped_and_not_linked_when_they_are_not_routes() {
134        let evil = "<script>alert(1)</script>";
135        let r = release(evil, &[("\"><img src=x onerror=alert(1)>", 5), ("fine-<b>.txt", 1)]);
136        let html = page("https://code.x.test", "whiskers", Repo::Whiskers.project(), true, &[r]).into_string();
137        assert!(!html.contains("<script>alert"), "{html}");
138        assert!(!html.contains("<img src=x"), "{html}");
139        assert!(!html.contains("<b>"), "{html}");
140        assert!(html.contains("&lt;script&gt;"), "{html}");
141        // Neither the tag nor the names are route segments, so nothing links to them.
142        assert!(!html.contains("/releases/&lt;"), "{html}");
143        assert!(!html.contains("download="), "{html}");
144    }
145
146    #[test]
147    fn no_release_is_said_and_the_page_opens_no_socket() {
148        let html = page("https://code.x.test", "whiskers", Repo::Whiskers.project(), true, &[]).into_string();
149        assert!(html.contains("No release yet."), "{html}");
150        assert!(!html.contains("id=\"online\""), "{html}");
151    }
152}