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'&' => "&",
18 b'<' => "<",
19 b'>' => ">",
20 b'"' => """,
21 b'\'' => "'",
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 '&' => "&",
37 '<' => "<",
38 '>' => ">",
39 '"' => """,
40 '\'' => "'",
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\">© {} {}</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 "<a x="'">&한글"
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 }