oni.git

oni

~ A static Git browser written in Rust.
git clone https://github.com/debuman/oni.git

generator/src/html.rs (8397B)

      1 use crate::repo::Repository;
      2 use std::fmt::{self, Write};
      3 use std::time::{SystemTime, UNIX_EPOCH};
      4 
      5 pub const SITE: &str = "oni.sqzer.com";
      6 pub const CSP: &str = include_str!("../../security/csp.txt");
      7 
      8 // Write escaped spans directly into the destination, without a temporary
      9 // allocation for each source line. ASCII metacharacters are UTF-8 boundaries.
     10 pub struct Escaped<'a>(pub &'a str);
     11 
     12 impl fmt::Display for Escaped<'_> {
     13     fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
     14         let mut start = 0;
     15         for (index, byte) in self.0.bytes().enumerate() {
     16             let replacement = match byte {
     17                 b'&' => "&amp;",
     18                 b'<' => "&lt;",
     19                 b'>' => "&gt;",
     20                 b'"' => "&quot;",
     21                 b'\'' => "&#39;",
     22                 _ => continue,
     23             };
     24             out.write_str(&self.0[start..index])?;
     25             out.write_str(replacement)?;
     26             start = index + 1;
     27         }
     28         out.write_str(&self.0[start..])
     29     }
     30 }
     31 
     32 pub fn escape(input: &str) -> String {
     33     let mut out = String::with_capacity(input.len());
     34     for character in input.chars() {
     35         out.push_str(match character {
     36             '&' => "&amp;",
     37             '<' => "&lt;",
     38             '>' => "&gt;",
     39             '"' => "&quot;",
     40             '\'' => "&#39;",
     41             _ => {
     42                 out.push(character);
     43                 continue;
     44             }
     45         });
     46     }
     47     out
     48 }
     49 
     50 pub fn url_path(input: &str) -> String {
     51     let mut out = String::new();
     52     for byte in input.bytes() {
     53         if byte.is_ascii_alphanumeric() || b"-._~/".contains(&byte) {
     54             out.push(byte as char);
     55         } else {
     56             write!(out, "%{byte:02X}").unwrap();
     57         }
     58     }
     59     out
     60 }
     61 
     62 pub fn link(url: &str, label: &str) -> String {
     63     if !safe_url(url) {
     64         return escape(label);
     65     }
     66     format!("<a href=\"{}\">{}</a>", escape(url), escape(label))
     67 }
     68 
     69 pub fn safe_url(url: &str) -> bool {
     70     if url
     71         .bytes()
     72         .any(|byte| byte <= b' ' || byte == 127 || byte == b'\\')
     73     {
     74         return false;
     75     }
     76     let first = url.split(['/', '?', '#']).next().unwrap_or("");
     77     match first.split_once(':') {
     78         Some((scheme, _)) => ["https", "http", "mailto"]
     79             .iter()
     80             .any(|allowed| scheme.eq_ignore_ascii_case(allowed)),
     81         None => true,
     82     }
     83 }
     84 
     85 // Convert a Unix timestamp to a Gregorian calendar date, in UTC.
     86 pub fn date(timestamp: i64) -> String {
     87     let days = timestamp.div_euclid(86400) + 719468;
     88     let era = days.div_euclid(146097);
     89     let day_of_era = days - era * 146097;
     90     let year_of_era =
     91         (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365;
     92     let mut year = year_of_era + era * 400;
     93     let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
     94     let month_index = (5 * day_of_year + 2) / 153;
     95     let day = day_of_year - (153 * month_index + 2) / 5 + 1;
     96     let month = month_index + if month_index < 10 { 3 } else { -9 };
     97     year += i64::from(month <= 2);
     98     format!("{year:04}-{month:02}-{day:02}")
     99 }
    100 
    101 fn document(title: &str, prefix: &str, feed: bool) -> String {
    102     let mut out = format!(
    103         "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
    104          <meta http-equiv=\"Content-Security-Policy\" content=\"{}\">\n\
    105          <meta name=\"referrer\" content=\"no-referrer\">\n\
    106          <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
    107          <title>{}</title>\n<link rel=\"stylesheet\" href=\"{prefix}style.css\">\n",
    108         escape(CSP.trim()),
    109         escape(title)
    110     );
    111     if feed {
    112         write!(out,
    113             "<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Commits\" href=\"{prefix}atom.xml\">\n\
    114              <link rel=\"alternate\" type=\"application/atom+xml\" title=\"Tags\" href=\"{prefix}tags.xml\">\n").unwrap();
    115     }
    116     out.push_str("</head>\n<body>\n");
    117     out
    118 }
    119 
    120 fn footer(holder: &str) -> String {
    121     let now = SystemTime::now()
    122         .duration_since(UNIX_EPOCH)
    123         .unwrap_or_default()
    124         .as_secs() as i64;
    125     let year = date(now);
    126     format!("</section>\n<footer class=\"page-footer\">&copy; {} {}</footer>\n</main>\n</body>\n</html>\n",
    127         &year[..4], escape(holder))
    128 }
    129 
    130 pub fn repository(repo: &Repository, title: &str, prefix: &str, body: &str) -> String {
    131     let mut out = document(
    132         &format!("{title} - {} - {}", repo.name, repo.description),
    133         prefix,
    134         true,
    135     );
    136     write!(
    137         out,
    138         "<div class=\"repository-id\">{}.git</div>\n\
    139         <main class=\"page repository\">\n<header class=\"panel\">\
    140         <table class=\"repository-summary\"><tbody>\n\
    141         <tr><td><h1>{}</h1><span class=\"summary-text\"> ~ {}</span></td></tr>\n",
    142         escape(&repo.name),
    143         escape(&repo.name),
    144         escape(&repo.description)
    145     )
    146     .unwrap();
    147     if !repo.clone_url.is_empty() {
    148         writeln!(
    149             out,
    150             "<tr><td><i>git clone {}</i></td></tr>",
    151             link(&repo.clone_url, &repo.clone_url)
    152         )
    153         .unwrap();
    154     }
    155     let mut navigation = vec![
    156         ("log.html".into(), "log"),
    157         ("files.html".into(), "files"),
    158         ("refs.html".into(), "refs"),
    159         ("archive.tar.gz".into(), "archive"),
    160     ];
    161     if repo.readme().is_some() {
    162         navigation.push(("README.html".into(), "README"));
    163     }
    164     if let Some(entry) = repo.license() {
    165         navigation.push((format!("file/{}.html", url_path(&entry.path)), "LICENSE"));
    166     }
    167     if repo.has_submodules {
    168         navigation.push(("file/.gitmodules.html".into(), "submodules"));
    169     }
    170     let links = navigation
    171         .iter()
    172         .map(|(path, label)| link(&format!("{prefix}{path}"), label))
    173         .collect::<Vec<_>>()
    174         .join(" | ");
    175     write!(
    176         out,
    177         "<tr><td><nav aria-label=\"Repository\">{links}</nav></td></tr>\n\
    178         </tbody></table></header>\n<section class=\"page-content\">\n{body}"
    179     )
    180     .unwrap();
    181     out.push_str(&footer(if repo.owner.is_empty() {
    182         "debuman"
    183     } else {
    184         &repo.owner
    185     }));
    186     out
    187 }
    188 
    189 pub fn index(repositories: &[Repository]) -> String {
    190     let mut out = document(SITE, "", false);
    191     write!(
    192         out,
    193         "<main class=\"page catalog\">\n<header class=\"panel\">\
    194         <table class=\"catalog-title\"><tbody><tr><td><b>{SITE} ~ repositories</b></td></tr>\
    195         </tbody></table></header>\n<section class=\"page-content\">\n\
    196         <table class=\"listing\" id=\"index\"><thead><tr><td><b>name</b></td>\
    197         <td><b>description</b></td><td><b>last commit</b></td></tr></thead><tbody>\n"
    198     )
    199     .unwrap();
    200     let mut sorted: Vec<_> = repositories.iter().collect();
    201     sorted.sort_by(|left, right| {
    202         let time = |repo: &Repository| {
    203             repo.commits
    204                 .first()
    205                 .map(|commit| commit.timestamp)
    206                 .unwrap_or(i64::MIN)
    207         };
    208         time(right)
    209             .cmp(&time(left))
    210             .then_with(|| left.name.cmp(&right.name))
    211     });
    212     for repo in sorted {
    213         let date = repo
    214             .commits
    215             .first()
    216             .map(|commit| date(commit.timestamp))
    217             .unwrap_or_default();
    218         writeln!(
    219             out,
    220             "<tr><td>{}</td><td>{}</td><td>{date}</td></tr>",
    221             link(&format!("{}/log.html", url_path(&repo.name)), &repo.name),
    222             escape(&repo.description)
    223         )
    224         .unwrap();
    225     }
    226     out.push_str("</tbody></table>\n");
    227     out.push_str(&footer(SITE));
    228     out
    229 }
    230 
    231 #[cfg(test)]
    232 mod tests {
    233     use super::*;
    234     #[test]
    235     fn escape_html_and_encode_file_paths() {
    236         assert_eq!(
    237             escape("<a x=\"'\">&한글"),
    238             "&lt;a x=&quot;&#39;&quot;&gt;&amp;한글"
    239         );
    240         assert_eq!(url_path("src/a b#?.rs"), "src/a%20b%23%3F.rs");
    241         assert_eq!(url_path("한"), "%ED%95%9C");
    242         for input in ["", "plain text", "<a x=\"'\">&한글", "🦀&<é>\"'", "&&"] {
    243             assert_eq!(Escaped(input).to_string(), escape(input));
    244         }
    245     }
    246     #[test]
    247     fn utc_calendar_handles_epoch_boundaries_and_leap_days() {
    248         assert_eq!(date(0), "1970-01-01");
    249         assert_eq!(date(-1), "1969-12-31");
    250         assert_eq!(date(951782400), "2000-02-29");
    251         assert_eq!(date(1709164800), "2024-02-29");
    252     }
    253 }