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::{
82 auth::{
83 CSRF_FIELD,
84 Csrf,
85 CurrentUser,
86 verify_csrf,
87 },
88 todomd,
89};
90
91const STYLE: &str = r#"
92:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
93* { box-sizing:border-box; }
94body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
95a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
96header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
97.container { max-width:980px; margin:0 auto; padding:0 16px; }
98header.top .container { display:flex; align-items:center; gap:12px; }
99.brand { font-weight:700; font-size:16px; color:var(--fg); }
100main { padding:12px 0 24px; }
101h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
102.muted { color:var(--muted); }
103.repo-list { list-style:none; padding:0; margin:0; }
104.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
105.repo-list .name { font-size:16px; font-weight:600; }
106.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
107.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
108.box .row:first-child { border-top:0; }
109.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
110.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; }
111.box .row a.fc-msg:hover { color:var(--accent); }
112.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
113.icon { width:16px; flex:none; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); }
114.icon.dir { color:#54aeff; }
115table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
116table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
117table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
118.cmds { background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:12px 14px; margin:8px 0; font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-x:auto; }
119.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
120.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
121.clone-tabs { display:flex; margin-left:auto; }
122.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; }
123.clone-tab:first-child { border-radius:2em 0 0 2em; margin-left:0; }
124.clone-tab:last-child { border-radius:0 2em 2em 0; }
125.clone-tab:first-child:last-child { border-radius:2em; }
126.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); z-index:1; }
127.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
128.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
129.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
130.copy-btn:hover { color:var(--fg); }
131.copied-msg { display:none; color:#1a7f37; font-size:12px; }
132.clone.copied .copied-msg { display:inline; }
133.clone.copied .copy-btn { color:#1a7f37; }
134.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
135.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); }
136.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
137.view-toggle { margin:8px 0; }
138a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
139.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
140.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
141.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; }
142.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; }
143.md-body pre code { background:none; padding:0; font-size:inherit; }
144.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
145.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
146.md-body img { max-width:100%; }
147.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
148.linkbtn:hover { text-decoration:underline; }
149.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; }
150.btn:hover { text-decoration:none; opacity:.92; }
151/* Repo header: title (+ visibility badge) on the left, quick-nav on the right;
152 wraps cleanly to its own line on narrow viewports instead of floating. */
153.repo-head { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:6px 16px; margin:24px 0 4px; }
154.repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; }
155.repo-title h1 { margin:0; }
156.repo-title .pill { font-size:11px; text-transform:uppercase; letter-spacing:.04em; align-self:center; }
157.repo-nav { font-size:13px; display:flex; align-items:baseline; gap:8px; color:var(--muted); }
158.repo-nav a { color:var(--muted); }
159.repo-nav a:hover { color:var(--accent); text-decoration:none; }
160.repo-nav .sep { color:var(--border); }
161.repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; }
162.repo-meta b { font-weight:600; color:var(--fg); }
163.pill-group { display:inline-flex; }
164.pill-group > .pill { border-radius:0; margin-left:-1px; position:relative; }
165.pill-group > .pill:first-child { border-radius:2em 0 0 2em; margin-left:0; }
166.pill-group > .pill:last-child { border-radius:0 2em 2em 0; }
167form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
168form.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; }
169form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
170form.stack select { padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; }
171form.stack textarea.editor { max-width:none; font:13px/1.5 ui-monospace,monospace; tab-size:4; resize:vertical; }
172p.file-actions { margin:8px 0; }
173table.usage { border-collapse:collapse; width:100%; max-width:680px; margin-top:12px; }
174table.usage th, table.usage td { padding:6px 10px; border-bottom:1px solid var(--border); text-align:left; }
175table.usage .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
176table.usage tfoot td { font-weight:600; border-top:2px solid var(--border); border-bottom:none; }
177.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
178.issue-dot.open { background:#1a7f37; }
179.issue-dot.closed { background:#8250df; }
180.st.issue-open { background:#dafbe1; color:#1a7f37; }
181.st.issue-closed { background:#fbefff; color:#8250df; }
182.issue-post { margin:12px 0; }
183.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
184.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
185.readme { margin-top:16px; }
186.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
187/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
188 nested frames. */
189.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
190.kanban .col { flex:1 1 0; min-width:240px; }
191.kanban .col h3 { margin:0 0 12px; padding:0 2px 8px; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); display:flex; align-items:baseline; gap:8px; border-bottom:1px solid var(--border); }
192.kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
193.kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
194.kanban .card .title p { margin:0; font-weight:500; }
195.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
196.kanban .card details { margin-top:7px; }
197.kanban .card summary { cursor:pointer; font-size:11px; font-weight:500; letter-spacing:.03em; text-transform:uppercase; color:var(--muted); list-style:none; display:inline-flex; align-items:center; gap:5px; user-select:none; }
198.kanban .card summary:hover { color:var(--accent); }
199.kanban .card summary::-webkit-details-marker { display:none; }
200.kanban .card summary::before { content:"\25B8"; font-size:9px; transition:transform .15s ease; }
201.kanban .card details[open] summary { margin-bottom:5px; }
202.kanban .card details[open] summary::before { transform:rotate(90deg); }
203.kanban .card .card-details { font-size:13px; color:var(--fg); line-height:1.5; }
204.kanban .card .card-details p { margin:0 0 6px; }
205.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
206.kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
207.kanban .card .card-details > :last-child { margin-bottom:0; }
208.kanban .card .title img { max-width:100%; height:auto; border-radius:4px; }
209.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
210.todo-notes { margin:8px 2px; }
211.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
212.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; }
213.latest-commit + .box { border-radius:0 0 6px 6px; }
214.commit-list { list-style:none; padding:0; margin:0; }
215.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
216.commit-list li:first-child { border-top:0; }
217.sha { font:12px ui-monospace,monospace; color:var(--muted); }
218.file-diff { margin:16px 0; }
219.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; }
220.file-diff summary.head::-webkit-details-marker { display:none; }
221.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
222.file-diff[open] summary.head::before { content:"\25BE"; }
223.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
224.file-diff .stat { margin-left:auto; white-space:nowrap; }
225.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
226table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
227table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
228table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
229table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
230table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
231.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
232.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
233.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
234.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
235.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
236.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; }
237footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
238details.nav-menu { position:relative; }
239details.nav-menu > summary { list-style:none; cursor:pointer; color:var(--accent); font-size:14px; }
240details.nav-menu > summary::-webkit-details-marker { display:none; }
241details.nav-menu > summary::after { content:" ▾"; font-size:10px; color:var(--muted); }
242.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; }
243.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; }
244.nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); }
245.nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; }
246.nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
247.nav-dropdown a.current { font-weight:600; }
248details.rev-menu { display:inline-block; }
249details.rev-menu > summary .pill { cursor:pointer; }
250"#;
251
252/// Clipboard icon for the clone "copy" button.
253const 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>"#;
254
255/// Filled folder icon for directory entries in the tree view.
256const 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>"#;
257
258/// Outline file icon for blob entries in the tree view.
259const 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>"#;
260
261/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
262/// Registered once on `document`, so it survives htmx body swaps.
263const CLONE_JS: &str = r#"
264(function(){
265 function copyText(t){
266 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
267 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
268 document.body.appendChild(ta); ta.focus(); ta.select();
269 try{document.execCommand('copy')}catch(e){}
270 document.body.removeChild(ta); return Promise.resolve();
271 }
272 document.addEventListener('click', function(e){
273 var nm=e.target.closest('details.nav-menu');
274 document.querySelectorAll('details.nav-menu').forEach(function(d){ if(d!==nm) d.removeAttribute('open'); });
275 var tab=e.target.closest('.clone-tab');
276 if(tab){
277 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
278 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
279 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
280 return;
281 }
282 var copy=e.target.closest('.copy-btn');
283 if(copy){
284 var box=copy.closest('.clone');
285 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
286 box.classList.add('copied');
287 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
288 });
289 }
290 });
291})();
292"#;
293
294/// Mount the web UI routes.
295pub fn routes(router: Router<App>) -> Router<App> {
296 router
297 .route("/", get(home))
298 .route("/-/settings", get(account_settings))
299 .route("/-/settings/keys", post(add_ssh_key))
300 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
301 .route("/-/new", get(new_repo_form).post(new_repo_submit))
302 .route("/{username}", get(user_profile))
303 .route(
304 "/{owner}/{repo}/settings",
305 get(repo_settings).post(repo_settings_submit),
306 )
307 .route("/{owner}/{repo}", get(repo_index))
308 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
309 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
310 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
311 .route(
312 "/{owner}/{repo}/edit/{rev}/{*path}",
313 get(edit_form).post(edit_submit),
314 )
315 .route(
316 "/{owner}/{repo}/add-task/{rev}/{*path}",
317 get(add_task_form).post(add_task_submit),
318 )
319 .route("/{owner}/{repo}/commits/{rev}", get(commits))
320 .route("/{owner}/{repo}/commit/{id}", get(commit))
321 .route("/{owner}/{repo}/ci", get(ci_runs))
322 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
323 .route("/-/static/htmx.min.js", get(htmx_js))
324}
325
326/// Serve the vendored htmx script (embedded in the binary).
327async fn htmx_js() -> Response {
328 (
329 [(
330 header::CONTENT_TYPE,
331 "application/javascript; charset=utf-8",
332 )],
333 include_str!("../assets/htmx.min.js"),
334 )
335 .into_response()
336}
337
338pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
339 // Attach the session's CSRF token to every htmx request as a header, so any
340 // JS-driven action carries it without a hidden field. Omitted (no attribute)
341 // when unauthenticated. The token is hex, so it needs no JSON escaping.
342 let csrf = crate::auth::current_csrf();
343 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
344 html! {
345 (DOCTYPE)
346 html lang="en" {
347 head {
348 meta charset="utf-8";
349 meta name="viewport" content="width=device-width, initial-scale=1";
350 title { (title) " · anvil" }
351 style { (PreEscaped(STYLE)) }
352 }
353 body hx-boost="true" hx-headers=[hx_headers] {
354 header.top { div.container {
355 a.brand href="/" { "anvil" }
356 span style="margin-left:auto" {
357 @match user {
358 Some(u) => {
359 details.nav-menu {
360 summary { (u.username) }
361 div.nav-dropdown {
362 a href="/-/settings" { "Settings" }
363 @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } }
364 form method="post" action="/-/logout" {
365 button type="submit" { "Sign out" }
366 }
367 }
368 }
369 }
370 None => { a href="/-/login" { "sign in" } }
371 }
372 }
373 } }
374 main { div.container { (body) } }
375 footer { div.container { "anvil — a git forge" } }
376 script src="/-/static/htmx.min.js" {}
377 script { (PreEscaped(CLONE_JS)) }
378 }
379 }
380 }
381}
382
383/// Hidden CSRF token field for embedding inside a mutating `<form>`.
384pub(crate) fn csrf_input(token: &str) -> Markup {
385 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
386}
387
388pub(crate) fn not_found(message: &str) -> Response {
389 (
390 StatusCode::NOT_FOUND,
391 layout(
392 "Not found",
393 None,
394 html! { h1 { "Not found" } p.muted { (message) } },
395 ),
396 )
397 .into_response()
398}
399
400pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
401 tracing::error!("ui error: {err}");
402 (
403 StatusCode::INTERNAL_SERVER_ERROR,
404 layout("Error", None, html! { h1 { "Something went wrong" } }),
405 )
406 .into_response()
407}
408
409/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
410/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
411pub(crate) async fn resolve_repo(
412 app: &App,
413 viewer: Option<&User>,
414 owner: &str,
415 name: &str,
416) -> Result<(PathBuf, Repository), Response> {
417 let owner_user = users::find_by_username(&app.db, owner)
418 .await
419 .map_err(server_error)?
420 .ok_or_else(|| not_found("no such user"))?;
421 let repo = repos::find(&app.db, owner_user.id, name)
422 .await
423 .map_err(server_error)?
424 .ok_or_else(|| not_found("no such repository"))?;
425 if !access::can_read(&repo, viewer) {
426 return Err(not_found("no such repository"));
427 }
428 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
429 if !path.exists() {
430 return Err(not_found("repository not found on disk"));
431 }
432 Ok((path, repo))
433}
434
435/// `GET /` — list repositories visible to the current user.
436async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
437 let all = repos::list_all_with_owner(&app.db)
438 .await
439 .map_err(server_error)?;
440 let repos: Vec<_> = all
441 .into_iter()
442 .filter(|r| {
443 !r.is_private
444 || user
445 .as_ref()
446 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
447 })
448 .collect();
449 Ok(layout(
450 "Repositories",
451 user.as_ref(),
452 html! {
453 div style="display:flex;align-items:center" {
454 h1 style="margin-right:auto" { "Repositories" }
455 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
456 }
457 @if repos.is_empty() {
458 p.muted {
459 "No repositories yet. "
460 @if user.is_some() { a href="/-/new" { "Create one" } "." }
461 @else { "Sign in to create one." }
462 }
463 } @else {
464 ul.repo-list {
465 @for r in &repos {
466 li {
467 div.name {
468 a href=(format!("/{}", r.owner)) { (r.owner) }
469 "/"
470 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
471 @if r.is_private { " " span.pill { "private" } }
472 }
473 @if !r.description.is_empty() { div.muted { (r.description) } }
474 }
475 }
476 }
477 }
478 },
479 ))
480}
481
482/// `GET /{username}` — a user's profile: their repositories (public to all;
483/// private only to themselves or an admin).
484async fn user_profile(
485 State(app): State<App>,
486 CurrentUser(viewer): CurrentUser,
487 Path(username): Path<String>,
488) -> Result<Markup, Response> {
489 let owner = users::find_by_username(&app.db, &username)
490 .await
491 .map_err(server_error)?
492 .ok_or_else(|| not_found("no such user"))?;
493 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
494 .await
495 .map_err(server_error)?
496 .into_iter()
497 .filter(|r| access::can_read(r, viewer.as_ref()))
498 .collect();
499 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
500
501 Ok(layout(
502 &owner.username,
503 viewer.as_ref(),
504 html! {
505 div style="display:flex;align-items:center" {
506 h1 style="margin-right:auto" { (owner.username) }
507 @if is_self { a.btn href="/-/new" { "New repository" } }
508 }
509 h2 { "Repositories" }
510 @if visible.is_empty() {
511 p.muted { "No repositories." }
512 } @else {
513 ul.repo-list {
514 @for r in &visible {
515 li {
516 div.name {
517 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
518 @if r.is_private { " " span.pill { "private" } }
519 }
520 @if !r.description.is_empty() { div.muted { (r.description) } }
521 }
522 }
523 }
524 }
525 },
526 ))
527}
528
529#[derive(serde::Deserialize)]
530struct AddKeyForm {
531 #[serde(default)]
532 title: String,
533 key: String,
534 #[serde(default)]
535 csrf: String,
536}
537
538/// `GET /settings` — account settings: profile + SSH keys.
539async fn account_settings(
540 State(app): State<App>,
541 CurrentUser(user): CurrentUser,
542 csrf: Csrf,
543) -> Response {
544 let Some(user) = user else {
545 return Redirect::to("/-/login").into_response();
546 };
547 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
548 Ok(keys) => keys,
549 Err(e) => return server_error(e),
550 };
551 account_page(&user, &keys, None, &csrf.0).into_response()
552}
553
554/// `POST /settings/keys` — register an SSH public key for the current user.
555async fn add_ssh_key(
556 State(app): State<App>,
557 CurrentUser(user): CurrentUser,
558 csrf: Csrf,
559 Form(form): Form<AddKeyForm>,
560) -> Response {
561 let Some(user) = user else {
562 return Redirect::to("/-/login").into_response();
563 };
564 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
565 return resp;
566 }
567 let result = match ssh_keys::parse_public_key(&form.key) {
568 Ok((fingerprint, content)) => {
569 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
570 .await
571 .map(|_| ())
572 }
573 Err(e) => Err(e),
574 };
575 match result {
576 Ok(()) => Redirect::to("/-/settings").into_response(),
577 Err(e) => {
578 let keys = ssh_keys::list_by_user(&app.db, user.id)
579 .await
580 .unwrap_or_default();
581 (
582 StatusCode::BAD_REQUEST,
583 account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
584 )
585 .into_response()
586 }
587 }
588}
589
590/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
591async fn delete_ssh_key(
592 State(app): State<App>,
593 CurrentUser(user): CurrentUser,
594 csrf: Csrf,
595 Path(id): Path<i64>,
596 Form(form): Form<crate::auth::CsrfForm>,
597) -> Response {
598 let Some(user) = user else {
599 return Redirect::to("/-/login").into_response();
600 };
601 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
602 return resp;
603 }
604 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
605 return server_error(e);
606 }
607 Redirect::to("/-/settings").into_response()
608}
609
610fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
611 layout(
612 "Account settings",
613 Some(user),
614 html! {
615 h1 { "Account settings" }
616 p.muted {
617 "Signed in as " strong { (user.username) }
618 @if !user.email.is_empty() { " · " (user.email) }
619 }
620
621 h2 { "SSH keys" }
622 p.muted { "Add a public key to clone and push over SSH." }
623 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
624 @if keys.is_empty() {
625 p.muted { "No SSH keys yet." }
626 } @else {
627 div.box {
628 @for k in keys {
629 div.row {
630 div {
631 @if !k.title.is_empty() { strong { (k.title) } " " }
632 span.sha { (k.fingerprint) }
633 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
634 }
635 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
636 (csrf_input(csrf))
637 button.linkbtn type="submit" { "delete" }
638 }
639 }
640 }
641 }
642 }
643
644 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
645 (csrf_input(csrf))
646 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
647 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
648 p { button.btn type="submit" { "Add SSH key" } }
649 }
650 },
651 )
652}
653
654pub(crate) fn forbidden() -> Response {
655 (
656 StatusCode::FORBIDDEN,
657 layout(
658 "Forbidden",
659 None,
660 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
661 ),
662 )
663 .into_response()
664}
665
666#[derive(serde::Deserialize)]
667struct NewRepoForm {
668 name: String,
669 #[serde(default)]
670 description: String,
671 private: Option<String>,
672 #[serde(default)]
673 csrf: String,
674}
675
676#[derive(serde::Deserialize)]
677struct SettingsForm {
678 #[serde(default)]
679 description: String,
680 private: Option<String>,
681 #[serde(default)]
682 mirror_url: String,
683 #[serde(default)]
684 csrf: String,
685}
686
687/// `GET /new` — new-repository form (requires login).
688async fn new_repo_form(
689 State(app): State<App>,
690 CurrentUser(user): CurrentUser,
691 csrf: Csrf,
692) -> Response {
693 let Some(user) = user else {
694 return Redirect::to("/-/login").into_response();
695 };
696 let remote = push_remote_url(&app, &user.username, "");
697 new_repo_page(&user, None, "", "", false, &remote, &csrf.0).into_response()
698}
699
700/// The remote URL to suggest for push-to-create: SSH when enabled (pushes
701/// without a credential prompt), otherwise HTTP. `name` may be empty, in which
702/// case a `<name>` placeholder is used.
703fn push_remote_url(app: &App, owner: &str, name: &str) -> String {
704 let name = if name.is_empty() { "<name>" } else { name };
705 if app.config.ssh.enabled {
706 app.config.ssh_clone_url(owner, name)
707 } else {
708 app.config.http_clone_url(owner, name)
709 }
710}
711
712/// `POST /new` — create a repository owned by the current user.
713async fn new_repo_submit(
714 State(app): State<App>,
715 CurrentUser(user): CurrentUser,
716 csrf: Csrf,
717 Form(form): Form<NewRepoForm>,
718) -> Response {
719 let Some(user) = user else {
720 return Redirect::to("/-/login").into_response();
721 };
722 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
723 return resp;
724 }
725 let private = form.private.is_some();
726 match repos::create(
727 &app.db,
728 &app.config.repositories_dir(),
729 &user,
730 &form.name,
731 &form.description,
732 private,
733 )
734 .await
735 {
736 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
737 Err(e) => {
738 let remote = push_remote_url(&app, &user.username, &form.name);
739 (
740 StatusCode::BAD_REQUEST,
741 new_repo_page(
742 &user,
743 Some(&e.to_string()),
744 &form.name,
745 &form.description,
746 private,
747 &remote,
748 &csrf.0,
749 ),
750 )
751 .into_response()
752 }
753 }
754}
755
756fn new_repo_page(
757 user: &User,
758 error: Option<&str>,
759 name: &str,
760 description: &str,
761 private: bool,
762 remote: &str,
763 csrf: &str,
764) -> Markup {
765 layout(
766 "New repository",
767 Some(user),
768 html! {
769 h1 { "New repository" }
770 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
771 form.stack method="post" action="/-/new" {
772 (csrf_input(csrf))
773 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
774 p { label { "Description" br; input type="text" name="description" value=(description); } }
775 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
776 p { button.btn type="submit" { "Create repository" } }
777 }
778 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
779
780 h2 { "…or push an existing repository" }
781 p.muted { "Pushing to a name that doesn't exist yet creates the repository (private). No need for the form above." }
782 pre.cmds { (format!("git remote add origin {remote}\ngit push -u origin main")) }
783 },
784 )
785}
786
787/// Load a repo for an owner-only settings action, enforcing write access.
788async fn resolve_for_settings(
789 app: &App,
790 viewer: Option<&User>,
791 owner: &str,
792 name: &str,
793) -> Result<Repository, Response> {
794 let owner_user = users::find_by_username(&app.db, owner)
795 .await
796 .map_err(server_error)?
797 .ok_or_else(|| not_found("no such repository"))?;
798 let repo = repos::find(&app.db, owner_user.id, name)
799 .await
800 .map_err(server_error)?
801 .ok_or_else(|| not_found("no such repository"))?;
802 if !access::can_read(&repo, viewer) {
803 return Err(not_found("no such repository"));
804 }
805 if !access::can_write(&repo, viewer) {
806 return Err(forbidden());
807 }
808 Ok(repo)
809}
810
811/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
812async fn repo_settings(
813 State(app): State<App>,
814 CurrentUser(user): CurrentUser,
815 csrf: Csrf,
816 Path((owner, repo)): Path<(String, String)>,
817) -> Response {
818 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
819 Ok(m) => m,
820 Err(resp) => return resp,
821 };
822 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
823}
824
825/// `POST /{owner}/{repo}/settings` — update description / visibility.
826async fn repo_settings_submit(
827 State(app): State<App>,
828 CurrentUser(user): CurrentUser,
829 csrf: Csrf,
830 Path((owner, repo)): Path<(String, String)>,
831 Form(form): Form<SettingsForm>,
832) -> Response {
833 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
834 Ok(m) => m,
835 Err(resp) => return resp,
836 };
837 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
838 return resp;
839 }
840 if let Err(e) = repos::update_settings(
841 &app.db,
842 meta.id,
843 &form.description,
844 form.private.is_some(),
845 &form.mirror_url,
846 )
847 .await
848 {
849 return server_error(e);
850 }
851 Redirect::to(&format!("/{owner}/{repo}")).into_response()
852}
853
854fn settings_page(
855 user: Option<&User>,
856 owner: &str,
857 repo: &str,
858 meta: &Repository,
859 error: Option<&str>,
860 csrf: &str,
861) -> Markup {
862 layout(
863 &format!("{owner}/{repo}: settings"),
864 user,
865 html! {
866 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
867 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
868 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
869 (csrf_input(csrf))
870 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
871 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
872 p {
873 label {
874 "Mirror push URL" br;
875 input type="text" name="mirror_url" value=(meta.mirror_url)
876 placeholder="https://x-access-token:<token>@github.com/you/repo.git";
877 }
878 br;
879 span.muted style="font-size:12px" {
880 "After every push here, all refs are mirrored to this remote ("
881 code { "git push --mirror" }
882 "). Stored as-is — use a scoped token. Empty disables it."
883 }
884 }
885 p { button.btn type="submit" { "Save changes" } }
886 }
887 },
888 )
889}
890
891fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
892 let http = app.config.http_clone_url(owner, name);
893 let ssh = app
894 .config
895 .ssh
896 .enabled
897 .then(|| app.config.ssh_clone_url(owner, name));
898 // SSH first and preselected when available — it's the protocol that can
899 // push without a credential prompt.
900 let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
901 html! {
902 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
903 div.clone-head {
904 span.muted { "Clone" }
905 div.clone-tabs {
906 @if ssh.is_some() {
907 button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
908 button.clone-tab type="button" data-proto="http" { "HTTP" }
909 } @else {
910 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
911 }
912 }
913 }
914 div.clone-cmd {
915 code { (default_cmd) }
916 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
917 (PreEscaped(CLIPBOARD_SVG))
918 }
919 span.copied-msg { "Copied!" }
920 }
921 }
922 }
923}
924
925/// `GET /{owner}/{repo}` — repository overview with the root tree.
926async fn repo_index(
927 State(app): State<App>,
928 CurrentUser(user): CurrentUser,
929 Path((owner, repo)): Path<(String, String)>,
930) -> Result<Markup, Response> {
931 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
932 let overview = browse::overview(&path).map_err(server_error)?;
933
934 let can_write = access::can_write(&meta, user.as_ref());
935 let header = html! {
936 div.repo-head {
937 span.repo-title {
938 h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) }
939 @if meta.is_private { span.pill { "private" } }
940 }
941 nav.repo-nav {
942 a href=(format!("/{owner}/{repo}/issues")) { "Issues" }
943 span.sep { "·" }
944 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
945 span.sep { "·" }
946 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
947 @if can_write {
948 span.sep { "·" }
949 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
950 }
951 }
952 }
953 @if !meta.description.is_empty() { p.muted { (meta.description) } }
954 p.repo-meta {
955 span { b { (overview.branches.len()) } " " (plural(overview.branches.len(), "branch", "branches")) }
956 span { b { (overview.tags.len()) } " " (plural(overview.tags.len(), "tag", "tags")) }
957 }
958 (clone_box(&app, &owner, &repo))
959 };
960
961 if overview.is_empty {
962 return Ok(layout(
963 &format!("{owner}/{repo}"),
964 user.as_ref(),
965 html! {
966 (header)
967 p.muted { "This repository is empty. Push to it to get started." }
968 },
969 ));
970 }
971
972 let rev = overview
973 .default_branch
974 .clone()
975 .unwrap_or_else(|| "HEAD".to_string());
976 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
977 let latest = browse::commit_log(&path, &rev, 1)
978 .map_err(server_error)?
979 .into_iter()
980 .next();
981 // Best-effort: a failed walk only costs the per-entry annotations.
982 let entry_commits =
983 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
984
985 // A root README renders below the tree, GitHub-style. Best-effort: a
986 // missing or unreadable file just omits the section.
987 let readme = entries
988 .iter()
989 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
990 .and_then(|e| {
991 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
992 Some((
993 render_markdown(&String::from_utf8_lossy(&bytes)),
994 e.name.clone(),
995 ))
996 });
997
998 // A root TODO.md with tasks renders as a kanban board below the README.
999 let todo_board = entries
1000 .iter()
1001 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
1002 .and_then(|e| {
1003 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
1004 let board = todomd::render_board(&String::from_utf8_lossy(&bytes))?;
1005 Some((board, e.name.clone()))
1006 });
1007
1008 Ok(layout(
1009 &format!("{owner}/{repo}"),
1010 user.as_ref(),
1011 html! {
1012 (header)
1013 p {
1014 (rev_switcher(&owner, &repo, &rev, &overview))
1015 " · "
1016 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
1017 }
1018 @if let Some(c) = &latest {
1019 div.latest-commit {
1020 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1021 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
1022 span.muted style="margin-left:auto" {
1023 (c.author) " · "
1024 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1025 }
1026 }
1027 }
1028 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
1029 @if let Some((rendered, name)) = &readme {
1030 div.box.readme {
1031 div.readme-head {
1032 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1033 }
1034 div.md-body { (rendered) }
1035 }
1036 }
1037 @if let Some((board, name)) = &todo_board {
1038 p.todo-board-head {
1039 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1040 }
1041 (board)
1042 }
1043 },
1044 ))
1045}
1046
1047async fn tree_root(
1048 State(app): State<App>,
1049 user: CurrentUser,
1050 Path((owner, repo, rev)): Path<(String, String, String)>,
1051) -> Result<Markup, Response> {
1052 render_tree(&app, user, &owner, &repo, &rev, "").await
1053}
1054
1055async fn tree_path(
1056 State(app): State<App>,
1057 user: CurrentUser,
1058 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1059) -> Result<Markup, Response> {
1060 render_tree(&app, user, &owner, &repo, &rev, &path).await
1061}
1062
1063async fn render_tree(
1064 app: &App,
1065 CurrentUser(user): CurrentUser,
1066 owner: &str,
1067 repo: &str,
1068 rev: &str,
1069 path: &str,
1070) -> Result<Markup, Response> {
1071 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
1072 let overview = browse::overview(&repo_path).map_err(server_error)?;
1073 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
1074 // Best-effort: a failed walk only costs the per-entry annotations.
1075 let entry_commits =
1076 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
1077 Ok(layout(
1078 &format!("{owner}/{repo}: {path}"),
1079 user.as_ref(),
1080 html! {
1081 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1082 p { (rev_switcher(owner, repo, rev, &overview)) }
1083 (breadcrumbs(owner, repo, rev, path, false))
1084 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
1085 },
1086 ))
1087}
1088
1089/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
1090/// by default; `?plain=1` shows the raw source (toggle links on the page).
1091async fn blob(
1092 State(app): State<App>,
1093 CurrentUser(user): CurrentUser,
1094 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1095 Query(query): Query<HashMap<String, String>>,
1096) -> Result<Markup, Response> {
1097 let (repo_path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1098 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
1099 .map_err(server_error)?
1100 .ok_or_else(|| not_found("file not found"))?;
1101
1102 // Editing writes a commit onto a branch, so it's offered only to writers
1103 // viewing a text file at a branch tip (not a tag or detached commit).
1104 let can_edit = !is_binary(&bytes)
1105 && access::can_write(&meta, user.as_ref())
1106 && browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).is_ok();
1107
1108 let markdown = is_markdown(&path) && !is_binary(&bytes);
1109 // Custom renderers for well-known filenames (the plugin point — add new
1110 // filename → renderer pairs here). TODO.md defaults to a kanban board.
1111 let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes);
1112 let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md"))
1113 .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes)))
1114 .flatten();
1115 let rendered = markdown && !query.contains_key("plain") && board.is_none();
1116
1117 let body = if let Some(board) = &board {
1118 board.clone()
1119 } else if is_binary(&bytes) {
1120 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
1121 } else if rendered {
1122 let text = String::from_utf8_lossy(&bytes);
1123 html! { div.md-body { (render_markdown(&text)) } }
1124 } else {
1125 let text = String::from_utf8_lossy(&bytes);
1126 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
1127 let lines = cached_highlight(budget, &oid, &path, &text);
1128 html! {
1129 table.code {
1130 @for (i, line) in lines.iter().enumerate() {
1131 tr {
1132 td.ln { (i + 1) }
1133 td { (PreEscaped(line)) }
1134 }
1135 }
1136 }
1137 }
1138 };
1139
1140 let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
1141 Ok(layout(
1142 &format!("{owner}/{repo}: {path}"),
1143 user.as_ref(),
1144 html! {
1145 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1146 (breadcrumbs(&owner, &repo, &rev, &path, true))
1147 @if can_edit {
1148 p.file-actions {
1149 a.btn.btn-secondary href=(format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(&rev))) { "Edit" }
1150 @if is_todo {
1151 " "
1152 a.btn.btn-secondary href=(format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(&rev))) { "Add task" }
1153 }
1154 }
1155 }
1156 @if markdown {
1157 p.view-toggle {
1158 span.pill-group {
1159 @if is_todo {
1160 @if board.is_some() { span.pill.active { "Board" } }
1161 @else { a.pill href=(&blob_url) { "Board" } }
1162 @if rendered { span.pill.active { "Rendered" } }
1163 @else { a.pill href=(format!("{blob_url}?md=1")) { "Rendered" } }
1164 } @else if rendered {
1165 span.pill.active { "Rendered" }
1166 } @else {
1167 a.pill href=(&blob_url) { "Rendered" }
1168 }
1169 @if rendered || board.is_some() {
1170 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
1171 } @else {
1172 span.pill.active { "Source" }
1173 }
1174 }
1175 }
1176 }
1177 @if board.is_some() {
1178 // The board supplies its own column structure; an enclosing
1179 // box would just nest frames.
1180 (body)
1181 } @else {
1182 div.box style="overflow-x:auto" { (body) }
1183 }
1184 },
1185 ))
1186}
1187
1188#[derive(serde::Deserialize)]
1189struct EditFileForm {
1190 csrf: String,
1191 /// Expected branch tip the editor saw — the compare-and-swap guard.
1192 expected_tip: String,
1193 message: String,
1194 content: String,
1195}
1196
1197/// Resolve a repo for a web edit, enforcing read+write access and that `rev`
1198/// names a branch (editing advances a branch ref). Returns the repo path and
1199/// the branch tip the editor is working from.
1200async fn resolve_for_edit(
1201 app: &App,
1202 user: Option<&User>,
1203 owner: &str,
1204 repo: &str,
1205 rev: &str,
1206) -> Result<(PathBuf, String), Response> {
1207 let (repo_path, meta) = resolve_repo(app, user, owner, repo).await?;
1208 if user.is_none() {
1209 return Err(Redirect::to("/-/login").into_response());
1210 }
1211 if !access::can_write(&meta, user) {
1212 return Err(forbidden());
1213 }
1214 let tip = browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}"))
1215 .map_err(|_| not_found("not an editable branch"))?;
1216 Ok((repo_path, tip))
1217}
1218
1219/// `GET /{owner}/{repo}/edit/{rev}/{*path}` — textarea editor for an existing
1220/// text file on a branch.
1221async fn edit_form(
1222 State(app): State<App>,
1223 CurrentUser(user): CurrentUser,
1224 csrf: Csrf,
1225 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1226) -> Response {
1227 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1228 Ok(v) => v,
1229 Err(resp) => return resp,
1230 };
1231 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1232 Ok(Some(b)) => b,
1233 Ok(None) => return not_found("file not found"),
1234 Err(e) => return server_error(e),
1235 };
1236 if is_binary(&bytes) {
1237 return bad_request_page(
1238 user.as_ref(),
1239 "Binary files can't be edited in the browser.",
1240 );
1241 }
1242 let content = String::from_utf8_lossy(&bytes).into_owned();
1243 edit_page(
1244 &owner,
1245 &repo,
1246 &rev,
1247 &path,
1248 &content,
1249 &format!("Update {path}"),
1250 &tip,
1251 None,
1252 user.as_ref(),
1253 &csrf.0,
1254 )
1255 .into_response()
1256}
1257
1258/// `POST /{owner}/{repo}/edit/{rev}/{*path}` — commit the edited content.
1259async fn edit_submit(
1260 State(app): State<App>,
1261 CurrentUser(user): CurrentUser,
1262 csrf: Csrf,
1263 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1264 Form(form): Form<EditFileForm>,
1265) -> Response {
1266 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1267 Ok((p, _)) => p,
1268 Err(resp) => return resp,
1269 };
1270 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1271 return resp;
1272 }
1273 let user = user.expect("resolve_for_edit requires a logged-in user");
1274
1275 // Browsers serialize textarea newlines as CRLF; normalize so an edit
1276 // doesn't rewrite every line ending.
1277 let content = form.content.replace("\r\n", "\n");
1278 let message = if form.message.trim().is_empty() {
1279 format!("Update {path}")
1280 } else {
1281 form.message.clone()
1282 };
1283
1284 match anvil_git::edit::commit_file_change(
1285 &repo_path,
1286 &rev,
1287 &form.expected_tip,
1288 &path,
1289 content.as_bytes(),
1290 &user.username,
1291 &user.email,
1292 &message,
1293 ) {
1294 Ok(_) => {
1295 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1296 }
1297 Err(e) => edit_page(
1298 &owner,
1299 &repo,
1300 &rev,
1301 &path,
1302 &content,
1303 &message,
1304 &form.expected_tip,
1305 Some(&e.to_string()),
1306 Some(&user),
1307 &csrf.0,
1308 )
1309 .into_response(),
1310 }
1311}
1312
1313/// The file-editor page: a textarea, a commit-message field, and the
1314/// compare-and-swap tip carried in a hidden field.
1315#[allow(clippy::too_many_arguments)]
1316fn edit_page(
1317 owner: &str,
1318 repo: &str,
1319 rev: &str,
1320 path: &str,
1321 content: &str,
1322 message: &str,
1323 expected_tip: &str,
1324 error: Option<&str>,
1325 user: Option<&User>,
1326 csrf: &str,
1327) -> Markup {
1328 let action = format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(rev));
1329 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1330 let upload_url = format!("/{owner}/{repo}/-/attachments");
1331 layout(
1332 &format!("Edit {path}"),
1333 user,
1334 html! {
1335 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1336 (breadcrumbs(owner, repo, rev, path, true))
1337 p.muted { "Editing on branch " code { (rev) } " — commits as you." }
1338 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1339 form.stack method="post" action=(action) {
1340 (csrf_input(csrf))
1341 input type="hidden" name="expected_tip" value=(expected_tip);
1342 p {
1343 textarea.editor name="content" rows="24" spellcheck="false" autofocus
1344 data-upload-url=(upload_url) data-csrf=(csrf) { (content) }
1345 }
1346 p.upload-hint {
1347 label.btn.btn-secondary.attach-btn {
1348 "Attach image"
1349 input.attach-input type="file" accept="image/*" multiple hidden;
1350 }
1351 " "
1352 span.muted { "or paste/drop one — it's stored outside git and a Markdown link is inserted." }
1353 }
1354 p { label { "Commit message" br; input type="text" name="message" value=(message); } }
1355 p {
1356 button.btn type="submit" { "Commit changes" }
1357 " "
1358 a.btn.btn-secondary href=(cancel) { "Cancel" }
1359 }
1360 }
1361 script { (PreEscaped(EDITOR_JS)) }
1362 },
1363 )
1364}
1365
1366/// Paste/drop-to-upload for the file editor: image clipboard items and dropped
1367/// image files are POSTed to the repo's attachment endpoint as a raw body, and
1368/// the returned Markdown is spliced into the textarea at the cursor. The blob
1369/// is stored outside git; only the URL lands in the file.
1370const EDITOR_JS: &str = r#"
1371(function(){
1372 var ta = document.querySelector('textarea.editor');
1373 if (!ta || !ta.dataset.uploadUrl) return;
1374 var url = ta.dataset.uploadUrl, csrf = ta.dataset.csrf;
1375 function insertAtCursor(text){
1376 var s = ta.selectionStart, e = ta.selectionEnd;
1377 ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
1378 ta.selectionStart = ta.selectionEnd = s + text.length;
1379 ta.focus();
1380 }
1381 function replaceFirst(find, repl){
1382 var i = ta.value.indexOf(find);
1383 if (i >= 0) ta.value = ta.value.slice(0, i) + repl + ta.value.slice(i + find.length);
1384 }
1385 function upload(file){
1386 var token = '![uploading ' + (file.name || 'image') + '…]()';
1387 insertAtCursor(token + '\n');
1388 fetch(url, {
1389 method: 'POST',
1390 headers: {'X-CSRF-Token': csrf, 'Content-Type': file.type || 'application/octet-stream'},
1391 body: file
1392 }).then(function(r){
1393 if (!r.ok) throw new Error('upload failed (' + r.status + ')');
1394 return r.json();
1395 }).then(function(d){
1396 replaceFirst(token, d.markdown);
1397 }).catch(function(err){
1398 replaceFirst(token, '![upload failed]()');
1399 console.error(err);
1400 });
1401 }
1402 ta.addEventListener('paste', function(ev){
1403 var items = (ev.clipboardData || {}).items || [];
1404 for (var i = 0; i < items.length; i++){
1405 if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0){
1406 ev.preventDefault();
1407 upload(items[i].getAsFile());
1408 }
1409 }
1410 });
1411 ta.addEventListener('dragover', function(ev){ ev.preventDefault(); });
1412 ta.addEventListener('drop', function(ev){
1413 var files = (ev.dataTransfer || {}).files || [], imgs = [];
1414 for (var i = 0; i < files.length; i++){
1415 if (files[i].type.indexOf('image/') === 0) imgs.push(files[i]);
1416 }
1417 if (imgs.length){ ev.preventDefault(); imgs.forEach(upload); }
1418 });
1419 // The "Attach image" button (works where paste/drop don't, e.g. mobile):
1420 // a file picker that uploads each chosen image.
1421 var picker = document.querySelector('input.attach-input');
1422 if (picker) picker.addEventListener('change', function(){
1423 var files = picker.files || [];
1424 for (var i = 0; i < files.length; i++){
1425 if (files[i].type.indexOf('image/') === 0) upload(files[i]);
1426 }
1427 picker.value = ''; // let the same file be re-picked
1428 });
1429})();
1430"#;
1431
1432#[derive(serde::Deserialize)]
1433struct AddTaskForm {
1434 csrf: String,
1435 expected_tip: String,
1436 section: String,
1437 title: String,
1438 #[serde(default)]
1439 body: String,
1440}
1441
1442/// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form
1443/// for a `TODO.md`, appending a `- [ ]` item per the todo-md round-trip rules.
1444async fn add_task_form(
1445 State(app): State<App>,
1446 CurrentUser(user): CurrentUser,
1447 csrf: Csrf,
1448 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1449) -> Response {
1450 let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1451 Ok(v) => v,
1452 Err(resp) => return resp,
1453 };
1454 if !todomd::is_todo_md(&path) {
1455 return not_found("not a TODO.md");
1456 }
1457 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1458 Ok(Some(b)) => b,
1459 Ok(None) => return not_found("file not found"),
1460 Err(e) => return server_error(e),
1461 };
1462 let sections = todomd::task_sections(&String::from_utf8_lossy(&bytes));
1463 if sections.is_empty() {
1464 return bad_request_page(user.as_ref(), "This TODO.md has no sections to add to.");
1465 }
1466 add_task_page(
1467 &owner,
1468 &repo,
1469 &rev,
1470 &path,
1471 &sections,
1472 "",
1473 "",
1474 &tip,
1475 None,
1476 user.as_ref(),
1477 &csrf.0,
1478 )
1479 .into_response()
1480}
1481
1482/// `POST /{owner}/{repo}/add-task/{rev}/{*path}` — append the task and commit.
1483async fn add_task_submit(
1484 State(app): State<App>,
1485 CurrentUser(user): CurrentUser,
1486 csrf: Csrf,
1487 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1488 Form(form): Form<AddTaskForm>,
1489) -> Response {
1490 let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await {
1491 Ok((p, _)) => p,
1492 Err(resp) => return resp,
1493 };
1494 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
1495 return resp;
1496 }
1497 let user = user.expect("resolve_for_edit requires a logged-in user");
1498 if !todomd::is_todo_md(&path) {
1499 return not_found("not a TODO.md");
1500 }
1501 let bytes = match browse::read_blob(&repo_path, &rev, &path) {
1502 Ok(Some(b)) => b,
1503 Ok(None) => return not_found("file not found"),
1504 Err(e) => return server_error(e),
1505 };
1506 let text = String::from_utf8_lossy(&bytes);
1507 let sections = todomd::task_sections(&text);
1508
1509 // Browsers serialize textarea newlines as CRLF; store LF.
1510 let body = form.body.replace("\r\n", "\n");
1511
1512 let render_err = |msg: &str, csrf: &Csrf| {
1513 add_task_page(
1514 &owner,
1515 &repo,
1516 &rev,
1517 &path,
1518 &sections,
1519 &form.title,
1520 &body,
1521 &form.expected_tip,
1522 Some(msg),
1523 Some(&user),
1524 &csrf.0,
1525 )
1526 .into_response()
1527 };
1528
1529 let Some(updated) = todomd::add_task(&text, &form.section, &form.title, &body) else {
1530 return render_err(
1531 "Couldn't add the task — check the title isn't empty and the section exists.",
1532 &csrf,
1533 );
1534 };
1535
1536 let message = format!("Add task to {}", form.section);
1537 match anvil_git::edit::commit_file_change(
1538 &repo_path,
1539 &rev,
1540 &form.expected_tip,
1541 &path,
1542 updated.as_bytes(),
1543 &user.username,
1544 &user.email,
1545 &message,
1546 ) {
1547 Ok(_) => {
1548 Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response()
1549 }
1550 Err(e) => render_err(&e.to_string(), &csrf),
1551 }
1552}
1553
1554/// The add-task form: a section dropdown, a title field, and a Markdown
1555/// description (which supports paste/drop image upload, like the file editor).
1556#[allow(clippy::too_many_arguments)]
1557fn add_task_page(
1558 owner: &str,
1559 repo: &str,
1560 rev: &str,
1561 path: &str,
1562 sections: &[String],
1563 title: &str,
1564 body: &str,
1565 expected_tip: &str,
1566 error: Option<&str>,
1567 user: Option<&User>,
1568 csrf: &str,
1569) -> Markup {
1570 let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev));
1571 let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev));
1572 let upload_url = format!("/{owner}/{repo}/-/attachments");
1573 layout(
1574 &format!("Add task · {path}"),
1575 user,
1576 html! {
1577 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1578 (breadcrumbs(owner, repo, rev, path, true))
1579 h2 { "Add a task" }
1580 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
1581 form.stack method="post" action=(action) {
1582 (csrf_input(csrf))
1583 input type="hidden" name="expected_tip" value=(expected_tip);
1584 p { label { "Section" br;
1585 select name="section" {
1586 @for s in sections { option value=(s) { (s) } }
1587 }
1588 } }
1589 p { label { "Title" br;
1590 input type="text" name="title" value=(title) placeholder="Short ticket title" autofocus;
1591 } }
1592 p { label { "Description" br;
1593 textarea.editor name="body" rows="10" spellcheck="false"
1594 placeholder="Markdown — attach an image with the button below, or paste/drop one"
1595 data-upload-url=(upload_url) data-csrf=(csrf) { (body) }
1596 } }
1597 p.upload-hint {
1598 label.btn.btn-secondary.attach-btn {
1599 "Attach image"
1600 input.attach-input type="file" accept="image/*" multiple hidden;
1601 }
1602 " "
1603 span.muted { "stored outside git; a Markdown link is inserted into the description." }
1604 }
1605 p {
1606 button.btn type="submit" { "Add task" }
1607 " "
1608 a.btn.btn-secondary href=(cancel) { "Cancel" }
1609 }
1610 }
1611 script { (PreEscaped(EDITOR_JS)) }
1612 },
1613 )
1614}
1615
1616/// A 400 page for malformed edit requests (binary file, no sections, …).
1617fn bad_request_page(user: Option<&User>, message: &str) -> Response {
1618 (
1619 StatusCode::BAD_REQUEST,
1620 layout(
1621 "Can't edit",
1622 user,
1623 html! { h1 { "Can't edit" } p.muted { (message) } },
1624 ),
1625 )
1626 .into_response()
1627}
1628
1629/// Pick the singular or plural noun for a count (`1 branch` / `2 branches`).
1630fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
1631 if n == 1 { one } else { many }
1632}
1633
1634/// Whether a path should be treated as markdown (by extension).
1635fn is_markdown(path: &str) -> bool {
1636 std::path::Path::new(path)
1637 .extension()
1638 .and_then(|e| e.to_str())
1639 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
1640}
1641
1642/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
1643///
1644/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
1645/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
1646/// link and image destinations are dropped.
1647pub(crate) fn render_markdown(text: &str) -> Markup {
1648 use pulldown_cmark::{
1649 Event,
1650 Options,
1651 Parser,
1652 Tag,
1653 html,
1654 };
1655
1656 fn safe_url(dest: &str) -> bool {
1657 let d = dest.trim().to_ascii_lowercase();
1658 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
1659 }
1660
1661 let opts = Options::ENABLE_TABLES
1662 | Options::ENABLE_STRIKETHROUGH
1663 | Options::ENABLE_TASKLISTS
1664 | Options::ENABLE_FOOTNOTES;
1665 let events = Parser::new_ext(text, opts).map(|ev| match ev {
1666 Event::Html(h) => Event::Text(h),
1667 Event::InlineHtml(h) => Event::Text(h),
1668 Event::Start(Tag::Link {
1669 link_type,
1670 dest_url,
1671 title,
1672 id,
1673 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
1674 link_type,
1675 dest_url: "".into(),
1676 title,
1677 id,
1678 }),
1679 Event::Start(Tag::Image {
1680 link_type,
1681 dest_url,
1682 title,
1683 id,
1684 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
1685 link_type,
1686 dest_url: "".into(),
1687 title,
1688 id,
1689 }),
1690 e => e,
1691 });
1692 let mut out = String::new();
1693 html::push_html(&mut out, events);
1694 PreEscaped(out)
1695}
1696
1697/// How far back the per-entry "latest commit" walk looks. Entries last touched
1698/// beyond this many commits just lose the annotation.
1699const ENTRY_LOG_WALK: usize = 400;
1700
1701/// Folder or file icon for an entry row (tree listings, pages, artifacts).
1702pub(crate) fn entry_icon(is_dir: bool) -> Markup {
1703 html! {
1704 @if is_dir {
1705 span.icon.dir { (PreEscaped(FOLDER_SVG)) }
1706 } @else {
1707 span.icon { (PreEscaped(FILE_SVG)) }
1708 }
1709 }
1710}
1711
1712/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
1713pub(crate) fn fmt_size(bytes: i64) -> String {
1714 let b = bytes.max(0) as f64;
1715 match b {
1716 b if b < 1024.0 => format!("{bytes} B"),
1717 b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
1718 b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
1719 b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
1720 }
1721}
1722
1723/// Percent-encode a ref name for use as one path segment in a URL. Axum
1724/// matches routes before decoding, so an encoded `/` keeps a branch like
1725/// `feat/x` inside the single `{rev}` segment.
1726pub(crate) fn enc_ref(name: &str) -> String {
1727 name.replace('%', "%25")
1728 .replace('/', "%2F")
1729 .replace('?', "%3F")
1730 .replace('#', "%23")
1731}
1732
1733/// Branch/tag switcher: a dropdown over the current rev linking each ref to
1734/// its tree view. Branch names, tag names, and commit ids all work as `rev`.
1735fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup {
1736 html! {
1737 details.nav-menu.rev-menu {
1738 summary { span.pill { (rev) } }
1739 div.nav-dropdown.left {
1740 @if !overview.branches.is_empty() {
1741 div.dd-head { "Branches" }
1742 @for b in &overview.branches {
1743 a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) }
1744 }
1745 }
1746 @if !overview.tags.is_empty() {
1747 div.dd-head { "Tags" }
1748 @for t in &overview.tags {
1749 a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) }
1750 }
1751 }
1752 }
1753 }
1754 }
1755}
1756
1757/// Render a tree listing as a box of rows; directories link to `tree`, files to
1758/// `blob`. Each entry also shows the subject of (and links to) the latest
1759/// commit that touched it, when `latest` has one for it.
1760fn tree_table(
1761 owner: &str,
1762 repo: &str,
1763 rev: &str,
1764 path: &str,
1765 entries: &[browse::TreeEntry],
1766 latest: &BTreeMap<String, browse::CommitInfo>,
1767) -> Markup {
1768 let join = |name: &str| {
1769 if path.is_empty() {
1770 name.to_string()
1771 } else {
1772 format!("{path}/{name}")
1773 }
1774 };
1775 html! {
1776 div.box {
1777 @if !path.is_empty() {
1778 div.row {
1779 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
1780 }
1781 }
1782 @for e in entries {
1783 @let child = join(&e.name);
1784 @let kind = if e.is_dir { "tree" } else { "blob" };
1785 div.row {
1786 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
1787 (entry_icon(e.is_dir))
1788 (e.name) @if e.is_dir { "/" }
1789 }
1790 @if let Some(c) = latest.get(&e.name) {
1791 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
1792 span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1793 }
1794 }
1795 }
1796 }
1797 }
1798}
1799
1800fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
1801 match path.rsplit_once('/') {
1802 Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)),
1803 None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)),
1804 }
1805}
1806
1807/// Path breadcrumbs. `is_blob` marks the final component as a file.
1808fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
1809 // Precompute (label, cumulative_path) for each path component.
1810 let mut crumbs: Vec<(String, String)> = Vec::new();
1811 let mut acc = String::new();
1812 for part in path.split('/').filter(|p| !p.is_empty()) {
1813 if !acc.is_empty() {
1814 acc.push('/');
1815 }
1816 acc.push_str(part);
1817 crumbs.push((part.to_string(), acc.clone()));
1818 }
1819 let last = crumbs.len();
1820 html! {
1821 div.crumbs {
1822 a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
1823 @for (i, (label, cum)) in crumbs.iter().enumerate() {
1824 " / "
1825 @if i + 1 == last && is_blob {
1826 span { (label) }
1827 } @else {
1828 a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
1829 }
1830 }
1831 }
1832 }
1833}
1834
1835/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
1836async fn commits(
1837 State(app): State<App>,
1838 CurrentUser(user): CurrentUser,
1839 Path((owner, repo, rev)): Path<(String, String, String)>,
1840) -> Result<Markup, Response> {
1841 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1842 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
1843
1844 // Map each commit oid to its latest run status, for inline badges. One query
1845 // for the repo's recent runs; first match wins (list is newest-first).
1846 let runs = ci::list_by_repo(&app.db, meta.id, 200)
1847 .await
1848 .unwrap_or_default();
1849 let mut status_of: HashMap<&str, &str> = HashMap::new();
1850 for r in &runs {
1851 status_of
1852 .entry(r.commit.as_str())
1853 .or_insert(r.status.as_str());
1854 }
1855
1856 Ok(layout(
1857 &format!("{owner}/{repo}: commits"),
1858 user.as_ref(),
1859 html! {
1860 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
1861 ul.commit-list {
1862 @for c in &log {
1863 li {
1864 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1865 @if let Some(st) = status_of.get(c.id.as_str()) {
1866 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
1867 }
1868 span { (c.summary) }
1869 span.muted style="margin-left:auto" {
1870 (c.author) " · "
1871 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1872 }
1873 }
1874 }
1875 }
1876 },
1877 ))
1878}
1879
1880/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
1881async fn commit(
1882 State(app): State<App>,
1883 CurrentUser(user): CurrentUser,
1884 Path((owner, repo, id)): Path<(String, String, String)>,
1885) -> Result<Markup, Response> {
1886 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1887 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
1888 Ok(layout(
1889 &format!("{owner}/{repo}: {}", detail.info.short),
1890 user.as_ref(),
1891 html! {
1892 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
1893 p { (detail.info.summary) }
1894 p.muted {
1895 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
1896 span.sha { (detail.info.id) }
1897 @if let Some(parent) = &detail.parent {
1898 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
1899 }
1900 " · "
1901 a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
1902 }
1903 @if detail.changes.is_empty() {
1904 p.muted { "No file changes." }
1905 }
1906 @for change in &detail.changes {
1907 (render_file_diff(change))
1908 }
1909 },
1910 ))
1911}
1912
1913/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
1914async fn ci_runs(
1915 State(app): State<App>,
1916 CurrentUser(user): CurrentUser,
1917 Path((owner, repo)): Path<(String, String)>,
1918) -> Result<Markup, Response> {
1919 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1920 let runs = ci::list_by_repo(&app.db, meta.id, 100)
1921 .await
1922 .map_err(server_error)?;
1923 Ok(layout(
1924 &format!("{owner}/{repo}: CI"),
1925 user.as_ref(),
1926 html! {
1927 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
1928 @if runs.is_empty() {
1929 p.muted {
1930 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
1931 " pipeline and push to trigger one."
1932 }
1933 } @else {
1934 div.box {
1935 @for r in &runs {
1936 div.row {
1937 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
1938 (status_badge(&r.status))
1939 span.sha { (short_commit(&r.commit)) }
1940 span { (r.ref_name) }
1941 }
1942 span.muted { (fmt_time(r.created_at)) }
1943 }
1944 }
1945 }
1946 }
1947 },
1948 ))
1949}
1950
1951/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1952async fn ci_run(
1953 State(app): State<App>,
1954 CurrentUser(user): CurrentUser,
1955 Path((owner, repo, id)): Path<(String, String, i64)>,
1956) -> Result<Markup, Response> {
1957 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1958 let run = ci::get(&app.db, id)
1959 .await
1960 .map_err(server_error)?
1961 .filter(|r| r.repo_id == meta.id)
1962 .ok_or_else(|| not_found("no such CI run"))?;
1963 let artifacts = ci::artifacts_for_run(&app.db, run.id)
1964 .await
1965 .map_err(server_error)?;
1966 Ok(layout(
1967 &format!("{owner}/{repo}: CI #{}", run.id),
1968 user.as_ref(),
1969 html! {
1970 h1 {
1971 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1972 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1973 " · #" (run.id)
1974 }
1975 p {
1976 (status_badge(&run.status))
1977 " "
1978 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1979 " " span.muted { (run.ref_name) }
1980 }
1981 p.muted {
1982 "queued " (fmt_time(run.created_at))
1983 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1984 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1985 @if let Some(d) = run_duration(&run) { " · took " (d) }
1986 }
1987 @if !artifacts.is_empty() {
1988 h2 { "Artifacts" }
1989 div.box {
1990 @for a in &artifacts {
1991 div.row {
1992 a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
1993 (entry_icon(a.is_dir))
1994 (a.name)
1995 @if a.browse { " " span.pill { "site" } }
1996 @else if a.is_dir { ".tar.gz" }
1997 }
1998 span.muted {
1999 (artifact_meta_chips(&a.meta))
2000 (fmt_size(a.size))
2001 }
2002 }
2003 }
2004 }
2005 }
2006 @if run.log.is_empty() {
2007 p.muted { "No output yet." }
2008 } @else {
2009 pre.log { (run.log) }
2010 }
2011 },
2012 ))
2013}
2014
2015/// Render an artifact's extractor metadata (a JSON object of key → value) as
2016/// inline `key: value` chips before the size.
2017fn artifact_meta_chips(meta: &str) -> Markup {
2018 let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
2019 html! {
2020 @for (k, v) in &map {
2021 span.pill title=(k) { (k) ": " (v) }
2022 " "
2023 }
2024 }
2025}
2026
2027/// A coloured status pill for a CI run status string.
2028fn status_badge(status: &str) -> Markup {
2029 html! { span class=(format!("st {status}")) { (status) } }
2030}
2031
2032/// First 8 hex chars of a commit oid (for compact display).
2033fn short_commit(commit: &str) -> &str {
2034 &commit[..commit.len().min(8)]
2035}
2036
2037/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
2038fn run_duration(run: &CiRun) -> Option<String> {
2039 if run.started_at > 0 && run.finished_at >= run.started_at {
2040 Some(format!("{}s", run.finished_at - run.started_at))
2041 } else {
2042 None
2043 }
2044}
2045
2046/// Render one file's diff (added/deleted/modified) as a unified line diff.
2047/// A file diff bigger than this many rows starts collapsed (its header still
2048/// shows the +/− counts; clicking expands it — native `details`, no JS).
2049const DIFF_COLLAPSE_ROWS: usize = 400;
2050
2051fn render_file_diff(change: &FileChange) -> Markup {
2052 let (badge_cls, badge) = match change.kind {
2053 ChangeKind::Added => ("add", "added"),
2054 ChangeKind::Deleted => ("del", "deleted"),
2055 ChangeKind::Modified => ("mod", "modified"),
2056 };
2057 let head = |stat: Markup| {
2058 html! {
2059 summary.head {
2060 span class=(format!("badge {badge_cls}")) { (badge) }
2061 span { (change.path) }
2062 span.stat { (stat) }
2063 }
2064 }
2065 };
2066
2067 let binary = change.old.as_deref().is_some_and(is_binary)
2068 || change.new.as_deref().is_some_and(is_binary);
2069 if binary {
2070 return html! {
2071 details.file-diff open {
2072 (head(html! { span.muted { "binary" } }))
2073 div.box { div.row { span.muted { "Binary file" } } }
2074 }
2075 };
2076 }
2077
2078 let old = change
2079 .old
2080 .as_deref()
2081 .map(|b| String::from_utf8_lossy(b).into_owned())
2082 .unwrap_or_default();
2083 let new = change
2084 .new
2085 .as_deref()
2086 .map(|b| String::from_utf8_lossy(b).into_owned())
2087 .unwrap_or_default();
2088 let diff = TextDiff::from_lines(&old, &new);
2089 let (mut adds, mut dels) = (0usize, 0usize);
2090 for c in diff.iter_all_changes() {
2091 match c.tag() {
2092 ChangeTag::Insert => adds += 1,
2093 ChangeTag::Delete => dels += 1,
2094 ChangeTag::Equal => {}
2095 }
2096 }
2097 // Hunks: changed lines plus 3 lines of context, not the whole file.
2098 let groups = diff.grouped_ops(3);
2099 let rendered_rows: usize = groups
2100 .iter()
2101 .flatten()
2102 .map(|op| diff.iter_changes(op).count())
2103 .sum();
2104
2105 html! {
2106 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
2107 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
2108 (diff_table(&diff, &groups, old.lines().count()))
2109 }
2110 }
2111}
2112
2113/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
2114/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
2115/// (including before the first hunk and after the last).
2116fn diff_table<'a>(
2117 diff: &TextDiff<'a, 'a, '_, str>,
2118 groups: &[Vec<similar::DiffOp>],
2119 old_total: usize,
2120) -> Markup {
2121 let gap_row = |n: usize| {
2122 html! {
2123 @if n > 0 {
2124 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
2125 }
2126 }
2127 };
2128 // Unchanged-line gap before each group, and after the last one.
2129 let mut prev_end = 0usize; // end of the previous group, in old-file lines
2130 let mut with_gaps = Vec::with_capacity(groups.len());
2131 for group in groups {
2132 let start = group.first().map_or(prev_end, |op| op.old_range().start);
2133 with_gaps.push((start.saturating_sub(prev_end), group));
2134 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
2135 }
2136 let trailing = old_total.saturating_sub(prev_end);
2137
2138 html! {
2139 table.code.diff {
2140 @for (gap, group) in &with_gaps {
2141 (gap_row(*gap))
2142 @for op in group.iter() {
2143 @for change in diff.iter_changes(op) {
2144 @let (sign, cls) = match change.tag() {
2145 ChangeTag::Delete => ("-", "del"),
2146 ChangeTag::Insert => ("+", "ins"),
2147 ChangeTag::Equal => (" ", ""),
2148 };
2149 tr class=(cls) {
2150 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
2151 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
2152 td.sign { (sign) }
2153 td { (change.value().trim_end_matches('\n')) }
2154 }
2155 }
2156 }
2157 }
2158 (gap_row(trailing))
2159 }
2160 }
2161}
2162
2163/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
2164fn highlighter() -> &'static (SyntaxSet, Theme) {
2165 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
2166 HL.get_or_init(|| {
2167 let syntaxes = SyntaxSet::load_defaults_newlines();
2168 let themes = ThemeSet::load_defaults();
2169 let theme = themes
2170 .themes
2171 .get("InspiredGitHub")
2172 .or_else(|| themes.themes.values().next())
2173 .cloned()
2174 .expect("at least one default theme");
2175 (syntaxes, theme)
2176 })
2177}
2178
2179/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
2180/// blob's rendered HTML is immutable for its object id (the extension is part
2181/// of the key because it picks the syntax), so each file is highlighted once
2182/// rather than once per request — highlighting large files is by far the most
2183/// expensive thing a page view can do. The budget is
2184/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
2185/// RAM-constrained hosts). Concurrent misses may both compute and the last
2186/// insert wins; that's benign.
2187fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
2188 if budget_bytes == 0 {
2189 return Arc::new(highlight(path, text));
2190 }
2191 struct Cache {
2192 lru: lru::LruCache<String, Arc<Vec<String>>>,
2193 bytes: usize,
2194 }
2195 fn cost(key: &str, lines: &[String]) -> usize {
2196 key.len() + lines.iter().map(String::len).sum::<usize>()
2197 }
2198 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
2199 let cache = CACHE.get_or_init(|| {
2200 Mutex::new(Cache {
2201 lru: lru::LruCache::unbounded(),
2202 bytes: 0,
2203 })
2204 });
2205
2206 let ext = std::path::Path::new(path)
2207 .extension()
2208 .and_then(|e| e.to_str())
2209 .unwrap_or("");
2210 let key = format!("{oid}\x00{ext}");
2211 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
2212 return hit.clone();
2213 }
2214
2215 let lines = Arc::new(highlight(path, text));
2216 let mut c = cache.lock().expect("cache lock");
2217 c.bytes += cost(&key, &lines);
2218 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
2219 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
2220 }
2221 // Evict oldest entries until we're back under budget. An entry larger than
2222 // the whole budget evicts itself — memory stays bounded, it just never caches.
2223 while c.bytes > budget_bytes {
2224 let Some((k, v)) = c.lru.pop_lru() else { break };
2225 c.bytes -= cost(&k, &v);
2226 }
2227 lines
2228}
2229
2230/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
2231/// Falls back to escaped plain text for large files or on any failure.
2232fn highlight(path: &str, text: &str) -> Vec<String> {
2233 if text.len() > 512 * 1024 {
2234 return text.lines().map(escape).collect();
2235 }
2236 let (syntaxes, theme) = highlighter();
2237 let syntax = std::path::Path::new(path)
2238 .extension()
2239 .and_then(|e| e.to_str())
2240 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
2241 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
2242 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
2243
2244 let mut h = HighlightLines::new(syntax, theme);
2245 text.lines()
2246 .map(|line| match h.highlight_line(line, syntaxes) {
2247 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
2248 .unwrap_or_else(|_| escape(line)),
2249 Err(_) => escape(line),
2250 })
2251 .collect()
2252}
2253
2254fn escape(s: &str) -> String {
2255 s.replace('&', "&amp;")
2256 .replace('<', "&lt;")
2257 .replace('>', "&gt;")
2258}
2259
2260/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
2261pub(crate) fn fmt_time(secs: i64) -> String {
2262 match OffsetDateTime::from_unix_timestamp(secs) {
2263 Ok(t) => format!(
2264 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
2265 t.year(),
2266 u8::from(t.month()),
2267 t.day(),
2268 t.hour(),
2269 t.minute()
2270 ),
2271 Err(_) => secs.to_string(),
2272 }
2273}
2274
2275/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
2276pub(crate) fn fmt_relative(secs: i64) -> String {
2277 relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
2278}
2279
2280fn relative_to(secs: i64, now: i64) -> String {
2281 fn ago(n: i64, one: &str, unit: &str) -> String {
2282 if n == 1 {
2283 one.to_string()
2284 } else {
2285 format!("{n} {unit}s ago")
2286 }
2287 }
2288 let delta = now - secs;
2289 if delta < 60 {
2290 return "just now".to_string();
2291 }
2292 let minutes = delta / 60;
2293 if minutes < 60 {
2294 return ago(minutes, "1 minute ago", "minute");
2295 }
2296 let hours = delta / 3600;
2297 if hours < 24 {
2298 return ago(hours, "1 hour ago", "hour");
2299 }
2300 let days = delta / 86_400;
2301 if days < 7 {
2302 return ago(days, "yesterday", "day");
2303 }
2304 let weeks = days / 7;
2305 if weeks < 5 {
2306 return ago(weeks, "last week", "week");
2307 }
2308 let months = days / 30;
2309 if months < 12 {
2310 return ago(months, "last month", "month");
2311 }
2312 ago(days / 365, "last year", "year")
2313}
2314
2315/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
2316fn is_binary(bytes: &[u8]) -> bool {
2317 bytes.iter().take(8192).any(|&b| b == 0)
2318}
2319
2320#[cfg(test)]
2321mod tests {
2322 use super::*;
2323
2324 #[test]
2325 fn markdown_by_extension_only() {
2326 assert!(is_markdown("README.md"));
2327 assert!(is_markdown("docs/guide.MarkDown"));
2328 assert!(!is_markdown("main.rs"));
2329 assert!(!is_markdown("md")); // no extension
2330 }
2331
2332 // Repo content is untrusted; rendered markdown must not become stored XSS.
2333 #[test]
2334 fn rendered_markdown_neutralizes_html_and_script_urls() {
2335 let out = render_markdown(
2336 "# 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",
2337 )
2338 .into_string();
2339 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
2340 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
2341 assert!(
2342 out.contains("&lt;script&gt;"),
2343 "raw HTML kept as text: {out}"
2344 );
2345 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
2346 assert!(!out.contains("data:"), "data URL dropped: {out}");
2347 assert!(
2348 out.contains(r#"href="https://example.com""#),
2349 "normal links survive: {out}"
2350 );
2351 }
2352
2353 #[test]
2354 fn relative_time_buckets() {
2355 const NOW: i64 = 1_000_000_000;
2356 let at = |delta: i64| relative_to(NOW - delta, NOW);
2357 assert_eq!(at(0), "just now");
2358 assert_eq!(at(59), "just now");
2359 assert_eq!(at(60), "1 minute ago");
2360 assert_eq!(at(45 * 60), "45 minutes ago");
2361 assert_eq!(at(3600), "1 hour ago");
2362 assert_eq!(at(23 * 3600), "23 hours ago");
2363 assert_eq!(at(86_400), "yesterday");
2364 assert_eq!(at(3 * 86_400), "3 days ago");
2365 assert_eq!(at(8 * 86_400), "last week");
2366 assert_eq!(at(20 * 86_400), "2 weeks ago");
2367 assert_eq!(at(40 * 86_400), "last month");
2368 assert_eq!(at(200 * 86_400), "6 months ago");
2369 assert_eq!(at(400 * 86_400), "last year");
2370 assert_eq!(at(900 * 86_400), "2 years ago");
2371 }
2372}