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