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