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