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