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