anvilsign in

collin/anvil

1//! Server-rendered web UI (Maud): repo list, repo overview, tree browsing, and
2//! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
3//! progressive enhancement is a follow-up.
4
5use std::{
6 collections::{
7 BTreeMap,
8 HashMap,
9 },
10 path::PathBuf,
11 sync::{
12 Arc,
13 Mutex,
14 OnceLock,
15 },
16};
17
18use anvil_core::{
19 App,
20 CiRun,
21 Repository,
22 SshKey,
23 User,
24 access,
25 ci,
26 repos,
27 ssh_keys,
28 users,
29};
30use anvil_git::browse::{
31 self,
32 ChangeKind,
33 FileChange,
34};
35use axum::{
36 Form,
37 Router,
38 extract::{
39 Path,
40 Query,
41 State,
42 },
43 http::{
44 StatusCode,
45 header,
46 },
47 response::{
48 IntoResponse,
49 Redirect,
50 Response,
51 },
52 routing::{
53 get,
54 post,
55 },
56};
57use maud::{
58 DOCTYPE,
59 Markup,
60 PreEscaped,
61 html,
62};
63use similar::{
64 ChangeTag,
65 TextDiff,
66};
67use syntect::{
68 easy::HighlightLines,
69 highlighting::{
70 Theme,
71 ThemeSet,
72 },
73 html::{
74 IncludeBackground,
75 styled_line_to_highlighted_html,
76 },
77 parsing::SyntaxSet,
78};
79use time::OffsetDateTime;
80
81use crate::auth::{
82 CSRF_FIELD,
83 Csrf,
84 CurrentUser,
85 verify_csrf,
86};
87
88const STYLE: &str = r#"
89:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
90* { box-sizing:border-box; }
91body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
92a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
93header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
94.container { max-width:980px; margin:0 auto; padding:0 16px; }
95header.top .container { display:flex; align-items:center; gap:12px; }
96.brand { font-weight:700; font-size:16px; color:var(--fg); }
97main { padding:24px 0; }
98h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
99.muted { color:var(--muted); }
100.repo-list { list-style:none; padding:0; margin:0; }
101.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
102.repo-list .name { font-size:16px; font-weight:600; }
103.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
104.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
105.box .row:first-child { border-top:0; }
106.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
107.box .row a.fc-msg { flex:1; margin-left:24px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; color:var(--muted); font-size:13px; }
108.box .row a.fc-msg:hover { color:var(--accent); }
109.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
110.icon { width:16px; flex:none; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); }
111.icon.dir { color:#54aeff; }
112table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
113table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
114table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
115.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
116.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
117.clone-tabs { display:flex; margin-left:auto; }
118.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:0; margin-left:-1px; position:relative; background:var(--bg); color:var(--muted); cursor:pointer; }
119.clone-tab:first-child { border-radius:2em 0 0 2em; margin-left:0; }
120.clone-tab:last-child { border-radius:0 2em 2em 0; }
121.clone-tab:first-child:last-child { border-radius:2em; }
122.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); z-index:1; }
123.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
124.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
125.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
126.copy-btn:hover { color:var(--fg); }
127.copied-msg { display:none; color:#1a7f37; font-size:12px; }
128.clone.copied .copied-msg { display:inline; }
129.clone.copied .copy-btn { color:#1a7f37; }
130.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
131.pill { display:inline-block; background:var(--code-bg); border:1px solid var(--border); border-radius:2em; padding:1px 8px; font-size:12px; color:var(--muted); }
132.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
133.view-toggle { margin:8px 0; }
134a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
135.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
136.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
137.md-body pre { background:var(--code-bg); border-radius:6px; padding:12px 14px; overflow-x:auto; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
138.md-body code { background:var(--code-bg); border-radius:4px; padding:1px 4px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:0.9em; }
139.md-body pre code { background:none; padding:0; font-size:inherit; }
140.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
141.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
142.md-body img { max-width:100%; }
143.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
144.linkbtn:hover { text-decoration:underline; }
145.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; }
146.btn:hover { text-decoration:none; opacity:.92; }
147.repo-nav { font-size:13px; }
148.repo-nav a { color:var(--muted); }
149.repo-nav a:hover { color:var(--accent); text-decoration:none; }
150.pill-group { display:inline-flex; }
151.pill-group > .pill { border-radius:0; margin-left:-1px; position:relative; }
152.pill-group > .pill:first-child { border-radius:2em 0 0 2em; margin-left:0; }
153.pill-group > .pill:last-child { border-radius:0 2em 2em 0; }
154form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
155form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
156form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
157.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
158.issue-dot.open { background:#1a7f37; }
159.issue-dot.closed { background:#8250df; }
160.st.issue-open { background:#dafbe1; color:#1a7f37; }
161.st.issue-closed { background:#fbefff; color:#8250df; }
162.issue-post { margin:12px 0; }
163.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
164.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
165.readme { margin-top:16px; }
166.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
167.latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
168.latest-commit + .box { border-radius:0 0 6px 6px; }
169.commit-list { list-style:none; padding:0; margin:0; }
170.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
171.commit-list li:first-child { border-top:0; }
172.sha { font:12px ui-monospace,monospace; color:var(--muted); }
173.file-diff { margin:16px 0; }
174.file-diff summary.head { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 12px; font:12px ui-monospace,monospace; cursor:pointer; display:flex; align-items:center; gap:8px; list-style:none; }
175.file-diff summary.head::-webkit-details-marker { display:none; }
176.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
177.file-diff[open] summary.head::before { content:"\25BE"; }
178.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
179.file-diff .stat { margin-left:auto; white-space:nowrap; }
180.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
181table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
182table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
183table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
184table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
185table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
186.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
187.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
188.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
189.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
190.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
191.log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; }
192footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
193details.nav-menu { position:relative; }
194details.nav-menu > summary { list-style:none; cursor:pointer; color:var(--accent); font-size:14px; }
195details.nav-menu > summary::-webkit-details-marker { display:none; }
196details.nav-menu > summary::after { content:" ▾"; font-size:10px; color:var(--muted); }
197.nav-dropdown { position:absolute; right:0; top:calc(100% + 6px); background:var(--bg); border:1px solid var(--border); border-radius:6px; min-width:130px; box-shadow:0 4px 14px rgba(0,0,0,.1); z-index:200; padding:4px 0; }
198.nav-dropdown a, .nav-dropdown button { display:block; width:100%; padding:6px 14px; font-size:13px; color:var(--fg); text-align:left; background:none; border:0; cursor:pointer; font:inherit; text-decoration:none; }
199.nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); }
200.nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; }
201.nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
202.nav-dropdown a.current { font-weight:600; }
203details.rev-menu { display:inline-block; }
204details.rev-menu > summary .pill { cursor:pointer; }
205"#;
206
207/// Clipboard icon for the clone "copy" button.
208const CLIPBOARD_SVG: &str = r#"<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></svg>"#;
209
210/// Filled folder icon for directory entries in the tree view.
211const FOLDER_SVG: &str = r#"<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>"#;
212
213/// Outline file icon for blob entries in the tree view.
214const FILE_SVG: &str = r#"<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>"#;
215
216/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
217/// Registered once on `document`, so it survives htmx body swaps.
218const CLONE_JS: &str = r#"
219(function(){
220 function copyText(t){
221 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
222 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
223 document.body.appendChild(ta); ta.focus(); ta.select();
224 try{document.execCommand('copy')}catch(e){}
225 document.body.removeChild(ta); return Promise.resolve();
226 }
227 document.addEventListener('click', function(e){
228 var nm=e.target.closest('details.nav-menu');
229 document.querySelectorAll('details.nav-menu').forEach(function(d){ if(d!==nm) d.removeAttribute('open'); });
230 var tab=e.target.closest('.clone-tab');
231 if(tab){
232 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
233 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
234 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
235 return;
236 }
237 var copy=e.target.closest('.copy-btn');
238 if(copy){
239 var box=copy.closest('.clone');
240 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
241 box.classList.add('copied');
242 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
243 });
244 }
245 });
246})();
247"#;
248
249/// Mount the web UI routes.
250pub fn routes(router: Router<App>) -> Router<App> {
251 router
252 .route("/", get(home))
253 .route("/-/settings", get(account_settings))
254 .route("/-/settings/keys", post(add_ssh_key))
255 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
256 .route("/-/new", get(new_repo_form).post(new_repo_submit))
257 .route("/{username}", get(user_profile))
258 .route(
259 "/{owner}/{repo}/settings",
260 get(repo_settings).post(repo_settings_submit),
261 )
262 .route("/{owner}/{repo}", get(repo_index))
263 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
264 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
265 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
266 .route("/{owner}/{repo}/commits/{rev}", get(commits))
267 .route("/{owner}/{repo}/commit/{id}", get(commit))
268 .route("/{owner}/{repo}/ci", get(ci_runs))
269 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
270 .route("/-/static/htmx.min.js", get(htmx_js))
271}
272
273/// Serve the vendored htmx script (embedded in the binary).
274async fn htmx_js() -> Response {
275 (
276 [(
277 header::CONTENT_TYPE,
278 "application/javascript; charset=utf-8",
279 )],
280 include_str!("../assets/htmx.min.js"),
281 )
282 .into_response()
283}
284
285pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
286 // Attach the session's CSRF token to every htmx request as a header, so any
287 // JS-driven action carries it without a hidden field. Omitted (no attribute)
288 // when unauthenticated. The token is hex, so it needs no JSON escaping.
289 let csrf = crate::auth::current_csrf();
290 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
291 html! {
292 (DOCTYPE)
293 html lang="en" {
294 head {
295 meta charset="utf-8";
296 meta name="viewport" content="width=device-width, initial-scale=1";
297 title { (title) " · anvil" }
298 style { (PreEscaped(STYLE)) }
299 }
300 body hx-boost="true" hx-headers=[hx_headers] {
301 header.top { div.container {
302 a.brand href="/" { "anvil" }
303 span style="margin-left:auto" {
304 @match user {
305 Some(u) => {
306 details.nav-menu {
307 summary { (u.username) }
308 div.nav-dropdown {
309 a href="/-/settings" { "Settings" }
310 form method="post" action="/-/logout" {
311 button type="submit" { "Sign out" }
312 }
313 }
314 }
315 }
316 None => { a href="/-/login" { "sign in" } }
317 }
318 }
319 } }
320 main { div.container { (body) } }
321 footer { div.container { "anvil — a git forge" } }
322 script src="/-/static/htmx.min.js" {}
323 script { (PreEscaped(CLONE_JS)) }
324 }
325 }
326 }
327}
328
329/// Hidden CSRF token field for embedding inside a mutating `<form>`.
330pub(crate) fn csrf_input(token: &str) -> Markup {
331 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
332}
333
334pub(crate) fn not_found(message: &str) -> Response {
335 (
336 StatusCode::NOT_FOUND,
337 layout(
338 "Not found",
339 None,
340 html! { h1 { "Not found" } p.muted { (message) } },
341 ),
342 )
343 .into_response()
344}
345
346pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
347 tracing::error!("ui error: {err}");
348 (
349 StatusCode::INTERNAL_SERVER_ERROR,
350 layout("Error", None, html! { h1 { "Something went wrong" } }),
351 )
352 .into_response()
353}
354
355/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
356/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
357pub(crate) async fn resolve_repo(
358 app: &App,
359 viewer: Option<&User>,
360 owner: &str,
361 name: &str,
362) -> Result<(PathBuf, Repository), Response> {
363 let owner_user = users::find_by_username(&app.db, owner)
364 .await
365 .map_err(server_error)?
366 .ok_or_else(|| not_found("no such user"))?;
367 let repo = repos::find(&app.db, owner_user.id, name)
368 .await
369 .map_err(server_error)?
370 .ok_or_else(|| not_found("no such repository"))?;
371 if !access::can_read(&repo, viewer) {
372 return Err(not_found("no such repository"));
373 }
374 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
375 if !path.exists() {
376 return Err(not_found("repository not found on disk"));
377 }
378 Ok((path, repo))
379}
380
381/// `GET /` — list repositories visible to the current user.
382async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
383 let all = repos::list_all_with_owner(&app.db)
384 .await
385 .map_err(server_error)?;
386 let repos: Vec<_> = all
387 .into_iter()
388 .filter(|r| {
389 !r.is_private
390 || user
391 .as_ref()
392 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
393 })
394 .collect();
395 Ok(layout(
396 "Repositories",
397 user.as_ref(),
398 html! {
399 div style="display:flex;align-items:center" {
400 h1 style="margin-right:auto" { "Repositories" }
401 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
402 }
403 @if repos.is_empty() {
404 p.muted {
405 "No repositories yet. "
406 @if user.is_some() { a href="/-/new" { "Create one" } "." }
407 @else { "Sign in to create one." }
408 }
409 } @else {
410 ul.repo-list {
411 @for r in &repos {
412 li {
413 div.name {
414 a href=(format!("/{}", r.owner)) { (r.owner) }
415 "/"
416 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
417 @if r.is_private { " " span.pill { "private" } }
418 }
419 @if !r.description.is_empty() { div.muted { (r.description) } }
420 }
421 }
422 }
423 }
424 },
425 ))
426}
427
428/// `GET /{username}` — a user's profile: their repositories (public to all;
429/// private only to themselves or an admin).
430async fn user_profile(
431 State(app): State<App>,
432 CurrentUser(viewer): CurrentUser,
433 Path(username): Path<String>,
434) -> Result<Markup, Response> {
435 let owner = users::find_by_username(&app.db, &username)
436 .await
437 .map_err(server_error)?
438 .ok_or_else(|| not_found("no such user"))?;
439 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
440 .await
441 .map_err(server_error)?
442 .into_iter()
443 .filter(|r| access::can_read(r, viewer.as_ref()))
444 .collect();
445 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
446
447 Ok(layout(
448 &owner.username,
449 viewer.as_ref(),
450 html! {
451 div style="display:flex;align-items:center" {
452 h1 style="margin-right:auto" { (owner.username) }
453 @if is_self { a.btn href="/-/new" { "New repository" } }
454 }
455 h2 { "Repositories" }
456 @if visible.is_empty() {
457 p.muted { "No repositories." }
458 } @else {
459 ul.repo-list {
460 @for r in &visible {
461 li {
462 div.name {
463 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
464 @if r.is_private { " " span.pill { "private" } }
465 }
466 @if !r.description.is_empty() { div.muted { (r.description) } }
467 }
468 }
469 }
470 }
471 },
472 ))
473}
474
475#[derive(serde::Deserialize)]
476struct AddKeyForm {
477 #[serde(default)]
478 title: String,
479 key: String,
480 #[serde(default)]
481 csrf: String,
482}
483
484/// `GET /settings` — account settings: profile + SSH keys.
485async fn account_settings(
486 State(app): State<App>,
487 CurrentUser(user): CurrentUser,
488 csrf: Csrf,
489) -> Response {
490 let Some(user) = user else {
491 return Redirect::to("/-/login").into_response();
492 };
493 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
494 Ok(keys) => keys,
495 Err(e) => return server_error(e),
496 };
497 account_page(&user, &keys, None, &csrf.0).into_response()
498}
499
500/// `POST /settings/keys` — register an SSH public key for the current user.
501async fn add_ssh_key(
502 State(app): State<App>,
503 CurrentUser(user): CurrentUser,
504 csrf: Csrf,
505 Form(form): Form<AddKeyForm>,
506) -> Response {
507 let Some(user) = user else {
508 return Redirect::to("/-/login").into_response();
509 };
510 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
511 return resp;
512 }
513 let result = match ssh_keys::parse_public_key(&form.key) {
514 Ok((fingerprint, content)) => {
515 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
516 .await
517 .map(|_| ())
518 }
519 Err(e) => Err(e),
520 };
521 match result {
522 Ok(()) => Redirect::to("/-/settings").into_response(),
523 Err(e) => {
524 let keys = ssh_keys::list_by_user(&app.db, user.id)
525 .await
526 .unwrap_or_default();
527 (
528 StatusCode::BAD_REQUEST,
529 account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
530 )
531 .into_response()
532 }
533 }
534}
535
536/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
537async fn delete_ssh_key(
538 State(app): State<App>,
539 CurrentUser(user): CurrentUser,
540 csrf: Csrf,
541 Path(id): Path<i64>,
542 Form(form): Form<crate::auth::CsrfForm>,
543) -> Response {
544 let Some(user) = user else {
545 return Redirect::to("/-/login").into_response();
546 };
547 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
548 return resp;
549 }
550 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
551 return server_error(e);
552 }
553 Redirect::to("/-/settings").into_response()
554}
555
556fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
557 layout(
558 "Account settings",
559 Some(user),
560 html! {
561 h1 { "Account settings" }
562 p.muted {
563 "Signed in as " strong { (user.username) }
564 @if !user.email.is_empty() { " · " (user.email) }
565 }
566
567 h2 { "SSH keys" }
568 p.muted { "Add a public key to clone and push over SSH." }
569 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
570 @if keys.is_empty() {
571 p.muted { "No SSH keys yet." }
572 } @else {
573 div.box {
574 @for k in keys {
575 div.row {
576 div {
577 @if !k.title.is_empty() { strong { (k.title) } " " }
578 span.sha { (k.fingerprint) }
579 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
580 }
581 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
582 (csrf_input(csrf))
583 button.linkbtn type="submit" { "delete" }
584 }
585 }
586 }
587 }
588 }
589
590 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
591 (csrf_input(csrf))
592 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
593 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
594 p { button.btn type="submit" { "Add SSH key" } }
595 }
596 },
597 )
598}
599
600fn forbidden() -> Response {
601 (
602 StatusCode::FORBIDDEN,
603 layout(
604 "Forbidden",
605 None,
606 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
607 ),
608 )
609 .into_response()
610}
611
612#[derive(serde::Deserialize)]
613struct NewRepoForm {
614 name: String,
615 #[serde(default)]
616 description: String,
617 private: Option<String>,
618 #[serde(default)]
619 csrf: String,
620}
621
622#[derive(serde::Deserialize)]
623struct SettingsForm {
624 #[serde(default)]
625 description: String,
626 private: Option<String>,
627 #[serde(default)]
628 mirror_url: String,
629 #[serde(default)]
630 csrf: String,
631}
632
633/// `GET /new` — new-repository form (requires login).
634async fn new_repo_form(CurrentUser(user): CurrentUser, csrf: Csrf) -> Response {
635 let Some(user) = user else {
636 return Redirect::to("/-/login").into_response();
637 };
638 new_repo_page(&user, None, "", "", false, &csrf.0).into_response()
639}
640
641/// `POST /new` — create a repository owned by the current user.
642async fn new_repo_submit(
643 State(app): State<App>,
644 CurrentUser(user): CurrentUser,
645 csrf: Csrf,
646 Form(form): Form<NewRepoForm>,
647) -> Response {
648 let Some(user) = user else {
649 return Redirect::to("/-/login").into_response();
650 };
651 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
652 return resp;
653 }
654 let private = form.private.is_some();
655 match repos::create(
656 &app.db,
657 &app.config.repositories_dir(),
658 &user,
659 &form.name,
660 &form.description,
661 private,
662 )
663 .await
664 {
665 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
666 Err(e) => (
667 StatusCode::BAD_REQUEST,
668 new_repo_page(
669 &user,
670 Some(&e.to_string()),
671 &form.name,
672 &form.description,
673 private,
674 &csrf.0,
675 ),
676 )
677 .into_response(),
678 }
679}
680
681fn new_repo_page(
682 user: &User,
683 error: Option<&str>,
684 name: &str,
685 description: &str,
686 private: bool,
687 csrf: &str,
688) -> Markup {
689 layout(
690 "New repository",
691 Some(user),
692 html! {
693 h1 { "New repository" }
694 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
695 form.stack method="post" action="/-/new" {
696 (csrf_input(csrf))
697 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
698 p { label { "Description" br; input type="text" name="description" value=(description); } }
699 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
700 p { button.btn type="submit" { "Create repository" } }
701 }
702 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
703 },
704 )
705}
706
707/// Load a repo for an owner-only settings action, enforcing write access.
708async fn resolve_for_settings(
709 app: &App,
710 viewer: Option<&User>,
711 owner: &str,
712 name: &str,
713) -> Result<Repository, Response> {
714 let owner_user = users::find_by_username(&app.db, owner)
715 .await
716 .map_err(server_error)?
717 .ok_or_else(|| not_found("no such repository"))?;
718 let repo = repos::find(&app.db, owner_user.id, name)
719 .await
720 .map_err(server_error)?
721 .ok_or_else(|| not_found("no such repository"))?;
722 if !access::can_read(&repo, viewer) {
723 return Err(not_found("no such repository"));
724 }
725 if !access::can_write(&repo, viewer) {
726 return Err(forbidden());
727 }
728 Ok(repo)
729}
730
731/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
732async fn repo_settings(
733 State(app): State<App>,
734 CurrentUser(user): CurrentUser,
735 csrf: Csrf,
736 Path((owner, repo)): Path<(String, String)>,
737) -> Response {
738 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
739 Ok(m) => m,
740 Err(resp) => return resp,
741 };
742 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
743}
744
745/// `POST /{owner}/{repo}/settings` — update description / visibility.
746async fn repo_settings_submit(
747 State(app): State<App>,
748 CurrentUser(user): CurrentUser,
749 csrf: Csrf,
750 Path((owner, repo)): Path<(String, String)>,
751 Form(form): Form<SettingsForm>,
752) -> Response {
753 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
754 Ok(m) => m,
755 Err(resp) => return resp,
756 };
757 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
758 return resp;
759 }
760 if let Err(e) = repos::update_settings(
761 &app.db,
762 meta.id,
763 &form.description,
764 form.private.is_some(),
765 &form.mirror_url,
766 )
767 .await
768 {
769 return server_error(e);
770 }
771 Redirect::to(&format!("/{owner}/{repo}")).into_response()
772}
773
774fn settings_page(
775 user: Option<&User>,
776 owner: &str,
777 repo: &str,
778 meta: &Repository,
779 error: Option<&str>,
780 csrf: &str,
781) -> Markup {
782 layout(
783 &format!("{owner}/{repo}: settings"),
784 user,
785 html! {
786 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
787 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
788 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
789 (csrf_input(csrf))
790 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
791 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
792 p {
793 label {
794 "Mirror push URL" br;
795 input type="text" name="mirror_url" value=(meta.mirror_url)
796 placeholder="https://x-access-token:<token>@github.com/you/repo.git";
797 }
798 br;
799 span.muted style="font-size:12px" {
800 "After every push here, all refs are mirrored to this remote ("
801 code { "git push --mirror" }
802 "). Stored as-is — use a scoped token. Empty disables it."
803 }
804 }
805 p { button.btn type="submit" { "Save changes" } }
806 }
807 },
808 )
809}
810
811fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
812 let http = app.config.http_clone_url(owner, name);
813 let ssh = app
814 .config
815 .ssh
816 .enabled
817 .then(|| app.config.ssh_clone_url(owner, name));
818 // SSH first and preselected when available — it's the protocol that can
819 // push without a credential prompt.
820 let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
821 html! {
822 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
823 div.clone-head {
824 span.muted { "Clone" }
825 div.clone-tabs {
826 @if ssh.is_some() {
827 button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
828 button.clone-tab type="button" data-proto="http" { "HTTP" }
829 } @else {
830 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
831 }
832 }
833 }
834 div.clone-cmd {
835 code { (default_cmd) }
836 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
837 (PreEscaped(CLIPBOARD_SVG))
838 }
839 span.copied-msg { "Copied!" }
840 }
841 }
842 }
843}
844
845/// `GET /{owner}/{repo}` — repository overview with the root tree.
846async fn repo_index(
847 State(app): State<App>,
848 CurrentUser(user): CurrentUser,
849 Path((owner, repo)): Path<(String, String)>,
850) -> Result<Markup, Response> {
851 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
852 let overview = browse::overview(&path).map_err(server_error)?;
853
854 let can_write = access::can_write(&meta, user.as_ref());
855 let header = html! {
856 div style="display:flex;align-items:center;gap:8px" {
857 h1 style="margin-right:auto" {
858 a href=(format!("/{owner}")) { (owner) } " / " (repo)
859 @if meta.is_private { " " span.pill { "private" } }
860 }
861 span.repo-nav {
862 a href=(format!("/{owner}/{repo}/issues")) { "Issues" }
863 " · "
864 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
865 " · "
866 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
867 @if can_write {
868 " · "
869 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
870 }
871 }
872 }
873 @if !meta.description.is_empty() { p.muted { (meta.description) } }
874 p {
875 span.pill { (overview.branches.len()) " branches" }
876 " "
877 span.pill { (overview.tags.len()) " tags" }
878 }
879 (clone_box(&app, &owner, &repo))
880 };
881
882 if overview.is_empty {
883 return Ok(layout(
884 &format!("{owner}/{repo}"),
885 user.as_ref(),
886 html! {
887 (header)
888 p.muted { "This repository is empty. Push to it to get started." }
889 },
890 ));
891 }
892
893 let rev = overview
894 .default_branch
895 .clone()
896 .unwrap_or_else(|| "HEAD".to_string());
897 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
898 let latest = browse::commit_log(&path, &rev, 1)
899 .map_err(server_error)?
900 .into_iter()
901 .next();
902 // Best-effort: a failed walk only costs the per-entry annotations.
903 let entry_commits =
904 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
905
906 // A root README renders below the tree, GitHub-style. Best-effort: a
907 // missing or unreadable file just omits the section.
908 let readme = entries
909 .iter()
910 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
911 .and_then(|e| {
912 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
913 Some((
914 render_markdown(&String::from_utf8_lossy(&bytes)),
915 e.name.clone(),
916 ))
917 });
918
919 Ok(layout(
920 &format!("{owner}/{repo}"),
921 user.as_ref(),
922 html! {
923 (header)
924 p {
925 (rev_switcher(&owner, &repo, &rev, &overview))
926 " · "
927 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
928 }
929 @if let Some(c) = &latest {
930 div.latest-commit {
931 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
932 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
933 span.muted style="margin-left:auto" {
934 (c.author) " · "
935 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
936 }
937 }
938 }
939 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
940 @if let Some((rendered, name)) = &readme {
941 div.box.readme {
942 div.readme-head {
943 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
944 }
945 div.md-body { (rendered) }
946 }
947 }
948 },
949 ))
950}
951
952async fn tree_root(
953 State(app): State<App>,
954 user: CurrentUser,
955 Path((owner, repo, rev)): Path<(String, String, String)>,
956) -> Result<Markup, Response> {
957 render_tree(&app, user, &owner, &repo, &rev, "").await
958}
959
960async fn tree_path(
961 State(app): State<App>,
962 user: CurrentUser,
963 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
964) -> Result<Markup, Response> {
965 render_tree(&app, user, &owner, &repo, &rev, &path).await
966}
967
968async fn render_tree(
969 app: &App,
970 CurrentUser(user): CurrentUser,
971 owner: &str,
972 repo: &str,
973 rev: &str,
974 path: &str,
975) -> Result<Markup, Response> {
976 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
977 let overview = browse::overview(&repo_path).map_err(server_error)?;
978 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
979 // Best-effort: a failed walk only costs the per-entry annotations.
980 let entry_commits =
981 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
982 Ok(layout(
983 &format!("{owner}/{repo}: {path}"),
984 user.as_ref(),
985 html! {
986 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
987 p { (rev_switcher(owner, repo, rev, &overview)) }
988 (breadcrumbs(owner, repo, rev, path, false))
989 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
990 },
991 ))
992}
993
994/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
995/// by default; `?plain=1` shows the raw source (toggle links on the page).
996async fn blob(
997 State(app): State<App>,
998 CurrentUser(user): CurrentUser,
999 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1000 Query(query): Query<HashMap<String, String>>,
1001) -> Result<Markup, Response> {
1002 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1003 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
1004 .map_err(server_error)?
1005 .ok_or_else(|| not_found("file not found"))?;
1006
1007 let markdown = is_markdown(&path) && !is_binary(&bytes);
1008 let rendered = markdown && !query.contains_key("plain");
1009
1010 let body = if is_binary(&bytes) {
1011 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
1012 } else if rendered {
1013 let text = String::from_utf8_lossy(&bytes);
1014 html! { div.md-body { (render_markdown(&text)) } }
1015 } else {
1016 let text = String::from_utf8_lossy(&bytes);
1017 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
1018 let lines = cached_highlight(budget, &oid, &path, &text);
1019 html! {
1020 table.code {
1021 @for (i, line) in lines.iter().enumerate() {
1022 tr {
1023 td.ln { (i + 1) }
1024 td { (PreEscaped(line)) }
1025 }
1026 }
1027 }
1028 }
1029 };
1030
1031 let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
1032 Ok(layout(
1033 &format!("{owner}/{repo}: {path}"),
1034 user.as_ref(),
1035 html! {
1036 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1037 (breadcrumbs(&owner, &repo, &rev, &path, true))
1038 @if markdown {
1039 p.view-toggle {
1040 span.pill-group {
1041 @if rendered {
1042 span.pill.active { "Rendered" }
1043 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
1044 } @else {
1045 a.pill href=(blob_url) { "Rendered" }
1046 span.pill.active { "Source" }
1047 }
1048 }
1049 }
1050 }
1051 div.box style="overflow-x:auto" { (body) }
1052 },
1053 ))
1054}
1055
1056/// Whether a path should be treated as markdown (by extension).
1057fn is_markdown(path: &str) -> bool {
1058 std::path::Path::new(path)
1059 .extension()
1060 .and_then(|e| e.to_str())
1061 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
1062}
1063
1064/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
1065///
1066/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
1067/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
1068/// link and image destinations are dropped.
1069pub(crate) fn render_markdown(text: &str) -> Markup {
1070 use pulldown_cmark::{
1071 Event,
1072 Options,
1073 Parser,
1074 Tag,
1075 html,
1076 };
1077
1078 fn safe_url(dest: &str) -> bool {
1079 let d = dest.trim().to_ascii_lowercase();
1080 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
1081 }
1082
1083 let opts = Options::ENABLE_TABLES
1084 | Options::ENABLE_STRIKETHROUGH
1085 | Options::ENABLE_TASKLISTS
1086 | Options::ENABLE_FOOTNOTES;
1087 let events = Parser::new_ext(text, opts).map(|ev| match ev {
1088 Event::Html(h) => Event::Text(h),
1089 Event::InlineHtml(h) => Event::Text(h),
1090 Event::Start(Tag::Link {
1091 link_type,
1092 dest_url,
1093 title,
1094 id,
1095 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
1096 link_type,
1097 dest_url: "".into(),
1098 title,
1099 id,
1100 }),
1101 Event::Start(Tag::Image {
1102 link_type,
1103 dest_url,
1104 title,
1105 id,
1106 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
1107 link_type,
1108 dest_url: "".into(),
1109 title,
1110 id,
1111 }),
1112 e => e,
1113 });
1114 let mut out = String::new();
1115 html::push_html(&mut out, events);
1116 PreEscaped(out)
1117}
1118
1119/// How far back the per-entry "latest commit" walk looks. Entries last touched
1120/// beyond this many commits just lose the annotation.
1121const ENTRY_LOG_WALK: usize = 400;
1122
1123/// Folder or file icon for an entry row (tree listings, pages, artifacts).
1124pub(crate) fn entry_icon(is_dir: bool) -> Markup {
1125 html! {
1126 @if is_dir {
1127 span.icon.dir { (PreEscaped(FOLDER_SVG)) }
1128 } @else {
1129 span.icon { (PreEscaped(FILE_SVG)) }
1130 }
1131 }
1132}
1133
1134/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
1135pub(crate) fn fmt_size(bytes: i64) -> String {
1136 let b = bytes.max(0) as f64;
1137 match b {
1138 b if b < 1024.0 => format!("{bytes} B"),
1139 b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
1140 b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
1141 b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
1142 }
1143}
1144
1145/// Percent-encode a ref name for use as one path segment in a URL. Axum
1146/// matches routes before decoding, so an encoded `/` keeps a branch like
1147/// `feat/x` inside the single `{rev}` segment.
1148pub(crate) fn enc_ref(name: &str) -> String {
1149 name.replace('%', "%25")
1150 .replace('/', "%2F")
1151 .replace('?', "%3F")
1152 .replace('#', "%23")
1153}
1154
1155/// Branch/tag switcher: a dropdown over the current rev linking each ref to
1156/// its tree view. Branch names, tag names, and commit ids all work as `rev`.
1157fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup {
1158 html! {
1159 details.nav-menu.rev-menu {
1160 summary { span.pill { (rev) } }
1161 div.nav-dropdown.left {
1162 @if !overview.branches.is_empty() {
1163 div.dd-head { "Branches" }
1164 @for b in &overview.branches {
1165 a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) }
1166 }
1167 }
1168 @if !overview.tags.is_empty() {
1169 div.dd-head { "Tags" }
1170 @for t in &overview.tags {
1171 a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) }
1172 }
1173 }
1174 }
1175 }
1176 }
1177}
1178
1179/// Render a tree listing as a box of rows; directories link to `tree`, files to
1180/// `blob`. Each entry also shows the subject of (and links to) the latest
1181/// commit that touched it, when `latest` has one for it.
1182fn tree_table(
1183 owner: &str,
1184 repo: &str,
1185 rev: &str,
1186 path: &str,
1187 entries: &[browse::TreeEntry],
1188 latest: &BTreeMap<String, browse::CommitInfo>,
1189) -> Markup {
1190 let join = |name: &str| {
1191 if path.is_empty() {
1192 name.to_string()
1193 } else {
1194 format!("{path}/{name}")
1195 }
1196 };
1197 html! {
1198 div.box {
1199 @if !path.is_empty() {
1200 div.row {
1201 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
1202 }
1203 }
1204 @for e in entries {
1205 @let child = join(&e.name);
1206 @let kind = if e.is_dir { "tree" } else { "blob" };
1207 div.row {
1208 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
1209 (entry_icon(e.is_dir))
1210 (e.name) @if e.is_dir { "/" }
1211 }
1212 @if let Some(c) = latest.get(&e.name) {
1213 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
1214 span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1215 }
1216 }
1217 }
1218 }
1219 }
1220}
1221
1222fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
1223 match path.rsplit_once('/') {
1224 Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)),
1225 None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)),
1226 }
1227}
1228
1229/// Path breadcrumbs. `is_blob` marks the final component as a file.
1230fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
1231 // Precompute (label, cumulative_path) for each path component.
1232 let mut crumbs: Vec<(String, String)> = Vec::new();
1233 let mut acc = String::new();
1234 for part in path.split('/').filter(|p| !p.is_empty()) {
1235 if !acc.is_empty() {
1236 acc.push('/');
1237 }
1238 acc.push_str(part);
1239 crumbs.push((part.to_string(), acc.clone()));
1240 }
1241 let last = crumbs.len();
1242 html! {
1243 div.crumbs {
1244 a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
1245 @for (i, (label, cum)) in crumbs.iter().enumerate() {
1246 " / "
1247 @if i + 1 == last && is_blob {
1248 span { (label) }
1249 } @else {
1250 a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
1251 }
1252 }
1253 }
1254 }
1255}
1256
1257/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
1258async fn commits(
1259 State(app): State<App>,
1260 CurrentUser(user): CurrentUser,
1261 Path((owner, repo, rev)): Path<(String, String, String)>,
1262) -> Result<Markup, Response> {
1263 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1264 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
1265
1266 // Map each commit oid to its latest run status, for inline badges. One query
1267 // for the repo's recent runs; first match wins (list is newest-first).
1268 let runs = ci::list_by_repo(&app.db, meta.id, 200)
1269 .await
1270 .unwrap_or_default();
1271 let mut status_of: HashMap<&str, &str> = HashMap::new();
1272 for r in &runs {
1273 status_of
1274 .entry(r.commit.as_str())
1275 .or_insert(r.status.as_str());
1276 }
1277
1278 Ok(layout(
1279 &format!("{owner}/{repo}: commits"),
1280 user.as_ref(),
1281 html! {
1282 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
1283 ul.commit-list {
1284 @for c in &log {
1285 li {
1286 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1287 @if let Some(st) = status_of.get(c.id.as_str()) {
1288 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
1289 }
1290 span { (c.summary) }
1291 span.muted style="margin-left:auto" {
1292 (c.author) " · "
1293 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1294 }
1295 }
1296 }
1297 }
1298 },
1299 ))
1300}
1301
1302/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
1303async fn commit(
1304 State(app): State<App>,
1305 CurrentUser(user): CurrentUser,
1306 Path((owner, repo, id)): Path<(String, String, String)>,
1307) -> Result<Markup, Response> {
1308 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1309 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
1310 Ok(layout(
1311 &format!("{owner}/{repo}: {}", detail.info.short),
1312 user.as_ref(),
1313 html! {
1314 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
1315 p { (detail.info.summary) }
1316 p.muted {
1317 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
1318 span.sha { (detail.info.id) }
1319 @if let Some(parent) = &detail.parent {
1320 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
1321 }
1322 " · "
1323 a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
1324 }
1325 @if detail.changes.is_empty() {
1326 p.muted { "No file changes." }
1327 }
1328 @for change in &detail.changes {
1329 (render_file_diff(change))
1330 }
1331 },
1332 ))
1333}
1334
1335/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
1336async fn ci_runs(
1337 State(app): State<App>,
1338 CurrentUser(user): CurrentUser,
1339 Path((owner, repo)): Path<(String, String)>,
1340) -> Result<Markup, Response> {
1341 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1342 let runs = ci::list_by_repo(&app.db, meta.id, 100)
1343 .await
1344 .map_err(server_error)?;
1345 Ok(layout(
1346 &format!("{owner}/{repo}: CI"),
1347 user.as_ref(),
1348 html! {
1349 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
1350 @if runs.is_empty() {
1351 p.muted {
1352 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
1353 " pipeline and push to trigger one."
1354 }
1355 } @else {
1356 div.box {
1357 @for r in &runs {
1358 div.row {
1359 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
1360 (status_badge(&r.status))
1361 span.sha { (short_commit(&r.commit)) }
1362 span { (r.ref_name) }
1363 }
1364 span.muted { (fmt_time(r.created_at)) }
1365 }
1366 }
1367 }
1368 }
1369 },
1370 ))
1371}
1372
1373/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1374async fn ci_run(
1375 State(app): State<App>,
1376 CurrentUser(user): CurrentUser,
1377 Path((owner, repo, id)): Path<(String, String, i64)>,
1378) -> Result<Markup, Response> {
1379 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1380 let run = ci::get(&app.db, id)
1381 .await
1382 .map_err(server_error)?
1383 .filter(|r| r.repo_id == meta.id)
1384 .ok_or_else(|| not_found("no such CI run"))?;
1385 let artifacts = ci::artifacts_for_run(&app.db, run.id)
1386 .await
1387 .map_err(server_error)?;
1388 Ok(layout(
1389 &format!("{owner}/{repo}: CI #{}", run.id),
1390 user.as_ref(),
1391 html! {
1392 h1 {
1393 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1394 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1395 " · #" (run.id)
1396 }
1397 p {
1398 (status_badge(&run.status))
1399 " "
1400 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1401 " " span.muted { (run.ref_name) }
1402 }
1403 p.muted {
1404 "queued " (fmt_time(run.created_at))
1405 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1406 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1407 @if let Some(d) = run_duration(&run) { " · took " (d) }
1408 }
1409 @if !artifacts.is_empty() {
1410 h2 { "Artifacts" }
1411 div.box {
1412 @for a in &artifacts {
1413 div.row {
1414 a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
1415 (entry_icon(a.is_dir))
1416 (a.name)
1417 @if a.browse { " " span.pill { "site" } }
1418 @else if a.is_dir { ".tar.gz" }
1419 }
1420 span.muted {
1421 (artifact_meta_chips(&a.meta))
1422 (fmt_size(a.size))
1423 }
1424 }
1425 }
1426 }
1427 }
1428 @if run.log.is_empty() {
1429 p.muted { "No output yet." }
1430 } @else {
1431 pre.log { (run.log) }
1432 }
1433 },
1434 ))
1435}
1436
1437/// Render an artifact's extractor metadata (a JSON object of key → value) as
1438/// inline `key: value` chips before the size.
1439fn artifact_meta_chips(meta: &str) -> Markup {
1440 let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
1441 html! {
1442 @for (k, v) in &map {
1443 span.pill title=(k) { (k) ": " (v) }
1444 " "
1445 }
1446 }
1447}
1448
1449/// A coloured status pill for a CI run status string.
1450fn status_badge(status: &str) -> Markup {
1451 html! { span class=(format!("st {status}")) { (status) } }
1452}
1453
1454/// First 8 hex chars of a commit oid (for compact display).
1455fn short_commit(commit: &str) -> &str {
1456 &commit[..commit.len().min(8)]
1457}
1458
1459/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
1460fn run_duration(run: &CiRun) -> Option<String> {
1461 if run.started_at > 0 && run.finished_at >= run.started_at {
1462 Some(format!("{}s", run.finished_at - run.started_at))
1463 } else {
1464 None
1465 }
1466}
1467
1468/// Render one file's diff (added/deleted/modified) as a unified line diff.
1469/// A file diff bigger than this many rows starts collapsed (its header still
1470/// shows the +/− counts; clicking expands it — native `details`, no JS).
1471const DIFF_COLLAPSE_ROWS: usize = 400;
1472
1473fn render_file_diff(change: &FileChange) -> Markup {
1474 let (badge_cls, badge) = match change.kind {
1475 ChangeKind::Added => ("add", "added"),
1476 ChangeKind::Deleted => ("del", "deleted"),
1477 ChangeKind::Modified => ("mod", "modified"),
1478 };
1479 let head = |stat: Markup| {
1480 html! {
1481 summary.head {
1482 span class=(format!("badge {badge_cls}")) { (badge) }
1483 span { (change.path) }
1484 span.stat { (stat) }
1485 }
1486 }
1487 };
1488
1489 let binary = change.old.as_deref().is_some_and(is_binary)
1490 || change.new.as_deref().is_some_and(is_binary);
1491 if binary {
1492 return html! {
1493 details.file-diff open {
1494 (head(html! { span.muted { "binary" } }))
1495 div.box { div.row { span.muted { "Binary file" } } }
1496 }
1497 };
1498 }
1499
1500 let old = change
1501 .old
1502 .as_deref()
1503 .map(|b| String::from_utf8_lossy(b).into_owned())
1504 .unwrap_or_default();
1505 let new = change
1506 .new
1507 .as_deref()
1508 .map(|b| String::from_utf8_lossy(b).into_owned())
1509 .unwrap_or_default();
1510 let diff = TextDiff::from_lines(&old, &new);
1511 let (mut adds, mut dels) = (0usize, 0usize);
1512 for c in diff.iter_all_changes() {
1513 match c.tag() {
1514 ChangeTag::Insert => adds += 1,
1515 ChangeTag::Delete => dels += 1,
1516 ChangeTag::Equal => {}
1517 }
1518 }
1519 // Hunks: changed lines plus 3 lines of context, not the whole file.
1520 let groups = diff.grouped_ops(3);
1521 let rendered_rows: usize = groups
1522 .iter()
1523 .flatten()
1524 .map(|op| diff.iter_changes(op).count())
1525 .sum();
1526
1527 html! {
1528 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
1529 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
1530 (diff_table(&diff, &groups, old.lines().count()))
1531 }
1532 }
1533}
1534
1535/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
1536/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
1537/// (including before the first hunk and after the last).
1538fn diff_table<'a>(
1539 diff: &TextDiff<'a, 'a, '_, str>,
1540 groups: &[Vec<similar::DiffOp>],
1541 old_total: usize,
1542) -> Markup {
1543 let gap_row = |n: usize| {
1544 html! {
1545 @if n > 0 {
1546 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
1547 }
1548 }
1549 };
1550 // Unchanged-line gap before each group, and after the last one.
1551 let mut prev_end = 0usize; // end of the previous group, in old-file lines
1552 let mut with_gaps = Vec::with_capacity(groups.len());
1553 for group in groups {
1554 let start = group.first().map_or(prev_end, |op| op.old_range().start);
1555 with_gaps.push((start.saturating_sub(prev_end), group));
1556 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
1557 }
1558 let trailing = old_total.saturating_sub(prev_end);
1559
1560 html! {
1561 table.code.diff {
1562 @for (gap, group) in &with_gaps {
1563 (gap_row(*gap))
1564 @for op in group.iter() {
1565 @for change in diff.iter_changes(op) {
1566 @let (sign, cls) = match change.tag() {
1567 ChangeTag::Delete => ("-", "del"),
1568 ChangeTag::Insert => ("+", "ins"),
1569 ChangeTag::Equal => (" ", ""),
1570 };
1571 tr class=(cls) {
1572 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
1573 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
1574 td.sign { (sign) }
1575 td { (change.value().trim_end_matches('\n')) }
1576 }
1577 }
1578 }
1579 }
1580 (gap_row(trailing))
1581 }
1582 }
1583}
1584
1585/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
1586fn highlighter() -> &'static (SyntaxSet, Theme) {
1587 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
1588 HL.get_or_init(|| {
1589 let syntaxes = SyntaxSet::load_defaults_newlines();
1590 let themes = ThemeSet::load_defaults();
1591 let theme = themes
1592 .themes
1593 .get("InspiredGitHub")
1594 .or_else(|| themes.themes.values().next())
1595 .cloned()
1596 .expect("at least one default theme");
1597 (syntaxes, theme)
1598 })
1599}
1600
1601/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
1602/// blob's rendered HTML is immutable for its object id (the extension is part
1603/// of the key because it picks the syntax), so each file is highlighted once
1604/// rather than once per request — highlighting large files is by far the most
1605/// expensive thing a page view can do. The budget is
1606/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
1607/// RAM-constrained hosts). Concurrent misses may both compute and the last
1608/// insert wins; that's benign.
1609fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
1610 if budget_bytes == 0 {
1611 return Arc::new(highlight(path, text));
1612 }
1613 struct Cache {
1614 lru: lru::LruCache<String, Arc<Vec<String>>>,
1615 bytes: usize,
1616 }
1617 fn cost(key: &str, lines: &[String]) -> usize {
1618 key.len() + lines.iter().map(String::len).sum::<usize>()
1619 }
1620 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
1621 let cache = CACHE.get_or_init(|| {
1622 Mutex::new(Cache {
1623 lru: lru::LruCache::unbounded(),
1624 bytes: 0,
1625 })
1626 });
1627
1628 let ext = std::path::Path::new(path)
1629 .extension()
1630 .and_then(|e| e.to_str())
1631 .unwrap_or("");
1632 let key = format!("{oid}\x00{ext}");
1633 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
1634 return hit.clone();
1635 }
1636
1637 let lines = Arc::new(highlight(path, text));
1638 let mut c = cache.lock().expect("cache lock");
1639 c.bytes += cost(&key, &lines);
1640 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
1641 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
1642 }
1643 // Evict oldest entries until we're back under budget. An entry larger than
1644 // the whole budget evicts itself — memory stays bounded, it just never caches.
1645 while c.bytes > budget_bytes {
1646 let Some((k, v)) = c.lru.pop_lru() else { break };
1647 c.bytes -= cost(&k, &v);
1648 }
1649 lines
1650}
1651
1652/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1653/// Falls back to escaped plain text for large files or on any failure.
1654fn highlight(path: &str, text: &str) -> Vec<String> {
1655 if text.len() > 512 * 1024 {
1656 return text.lines().map(escape).collect();
1657 }
1658 let (syntaxes, theme) = highlighter();
1659 let syntax = std::path::Path::new(path)
1660 .extension()
1661 .and_then(|e| e.to_str())
1662 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1663 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1664 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1665
1666 let mut h = HighlightLines::new(syntax, theme);
1667 text.lines()
1668 .map(|line| match h.highlight_line(line, syntaxes) {
1669 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1670 .unwrap_or_else(|_| escape(line)),
1671 Err(_) => escape(line),
1672 })
1673 .collect()
1674}
1675
1676fn escape(s: &str) -> String {
1677 s.replace('&', "&amp;")
1678 .replace('<', "&lt;")
1679 .replace('>', "&gt;")
1680}
1681
1682/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1683pub(crate) fn fmt_time(secs: i64) -> String {
1684 match OffsetDateTime::from_unix_timestamp(secs) {
1685 Ok(t) => format!(
1686 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1687 t.year(),
1688 u8::from(t.month()),
1689 t.day(),
1690 t.hour(),
1691 t.minute()
1692 ),
1693 Err(_) => secs.to_string(),
1694 }
1695}
1696
1697/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
1698pub(crate) fn fmt_relative(secs: i64) -> String {
1699 relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
1700}
1701
1702fn relative_to(secs: i64, now: i64) -> String {
1703 fn ago(n: i64, one: &str, unit: &str) -> String {
1704 if n == 1 {
1705 one.to_string()
1706 } else {
1707 format!("{n} {unit}s ago")
1708 }
1709 }
1710 let delta = now - secs;
1711 if delta < 60 {
1712 return "just now".to_string();
1713 }
1714 let minutes = delta / 60;
1715 if minutes < 60 {
1716 return ago(minutes, "1 minute ago", "minute");
1717 }
1718 let hours = delta / 3600;
1719 if hours < 24 {
1720 return ago(hours, "1 hour ago", "hour");
1721 }
1722 let days = delta / 86_400;
1723 if days < 7 {
1724 return ago(days, "yesterday", "day");
1725 }
1726 let weeks = days / 7;
1727 if weeks < 5 {
1728 return ago(weeks, "last week", "week");
1729 }
1730 let months = days / 30;
1731 if months < 12 {
1732 return ago(months, "last month", "month");
1733 }
1734 ago(days / 365, "last year", "year")
1735}
1736
1737/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1738fn is_binary(bytes: &[u8]) -> bool {
1739 bytes.iter().take(8192).any(|&b| b == 0)
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745
1746 #[test]
1747 fn markdown_by_extension_only() {
1748 assert!(is_markdown("README.md"));
1749 assert!(is_markdown("docs/guide.MarkDown"));
1750 assert!(!is_markdown("main.rs"));
1751 assert!(!is_markdown("md")); // no extension
1752 }
1753
1754 // Repo content is untrusted; rendered markdown must not become stored XSS.
1755 #[test]
1756 fn rendered_markdown_neutralizes_html_and_script_urls() {
1757 let out = render_markdown(
1758 "# title\n\n<script>alert(1)</script>\n\n[x](javascript:alert(1))\n\n![y](data:text/html,evil)\n\n[ok](https://example.com)\n",
1759 )
1760 .into_string();
1761 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
1762 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
1763 assert!(
1764 out.contains("&lt;script&gt;"),
1765 "raw HTML kept as text: {out}"
1766 );
1767 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
1768 assert!(!out.contains("data:"), "data URL dropped: {out}");
1769 assert!(
1770 out.contains(r#"href="https://example.com""#),
1771 "normal links survive: {out}"
1772 );
1773 }
1774
1775 #[test]
1776 fn relative_time_buckets() {
1777 const NOW: i64 = 1_000_000_000;
1778 let at = |delta: i64| relative_to(NOW - delta, NOW);
1779 assert_eq!(at(0), "just now");
1780 assert_eq!(at(59), "just now");
1781 assert_eq!(at(60), "1 minute ago");
1782 assert_eq!(at(45 * 60), "45 minutes ago");
1783 assert_eq!(at(3600), "1 hour ago");
1784 assert_eq!(at(23 * 3600), "23 hours ago");
1785 assert_eq!(at(86_400), "yesterday");
1786 assert_eq!(at(3 * 86_400), "3 days ago");
1787 assert_eq!(at(8 * 86_400), "last week");
1788 assert_eq!(at(20 * 86_400), "2 weeks ago");
1789 assert_eq!(at(40 * 86_400), "last month");
1790 assert_eq!(at(200 * 86_400), "6 months ago");
1791 assert_eq!(at(400 * 86_400), "last year");
1792 assert_eq!(at(900 * 86_400), "2 years ago");
1793 }
1794}