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 App,
20 CiRun,
21 Repository,
22 SshKey,
23 User,
24 access,
25 ci,
26 repos,
27 ssh_keys,
28 users,
29};
30use anvil_git::browse::{
31 self,
32 ChangeKind,
33 FileChange,
34};
35use axum::{
36 Form,
37 Router,
38 extract::{
39 Path,
40 Query,
41 State,
42 },
43 http::{
44 StatusCode,
45 header,
46 },
47 response::{
48 IntoResponse,
49 Redirect,
50 Response,
51 },
52 routing::{
53 get,
54 post,
55 },
56};
57use maud::{
58 DOCTYPE,
59 Markup,
60 PreEscaped,
61 html,
62};
63use similar::{
64 ChangeTag,
65 TextDiff,
66};
67use syntect::{
68 easy::HighlightLines,
69 highlighting::{
70 Theme,
71 ThemeSet,
72 },
73 html::{
74 IncludeBackground,
75 styled_line_to_highlighted_html,
76 },
77 parsing::SyntaxSet,
78};
79use time::OffsetDateTime;
80
81use crate::{
82 auth::{
83 CSRF_FIELD,
84 Csrf,
85 CurrentUser,
86 verify_csrf,
87 },
88 todomd,
89};
90
91const STYLE: &str = r#"
92:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
93* { box-sizing:border-box; }
94body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
95a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
96header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
97.container { max-width:980px; margin:0 auto; padding:0 16px; }
98header.top .container { display:flex; align-items:center; gap:12px; }
99.brand { font-weight:700; font-size:16px; color:var(--fg); }
100main { padding:24px 0; }
101h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
102.muted { color:var(--muted); }
103.repo-list { list-style:none; padding:0; margin:0; }
104.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
105.repo-list .name { font-size:16px; font-weight:600; }
106.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
107.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
108.box .row:first-child { border-top:0; }
109.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
110.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; }
111.box .row a.fc-msg:hover { color:var(--accent); }
112.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
113.icon { width:16px; flex:none; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); }
114.icon.dir { color:#54aeff; }
115table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
116table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
117table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
118.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; }
119.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
120.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
121.clone-tabs { display:flex; margin-left:auto; }
122.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; }
123.clone-tab:first-child { border-radius:2em 0 0 2em; margin-left:0; }
124.clone-tab:last-child { border-radius:0 2em 2em 0; }
125.clone-tab:first-child:last-child { border-radius:2em; }
126.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); z-index:1; }
127.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
128.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
129.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
130.copy-btn:hover { color:var(--fg); }
131.copied-msg { display:none; color:#1a7f37; font-size:12px; }
132.clone.copied .copied-msg { display:inline; }
133.clone.copied .copy-btn { color:#1a7f37; }
134.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
135.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); }
136.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
137.view-toggle { margin:8px 0; }
138a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
139.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
140.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
141.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; }
142.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; }
143.md-body pre code { background:none; padding:0; font-size:inherit; }
144.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
145.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
146.md-body img { max-width:100%; }
147.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
148.linkbtn:hover { text-decoration:underline; }
149.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; }
150.btn:hover { text-decoration:none; opacity:.92; }
151/* Repo header: title (+ visibility badge) on the left, quick-nav on the right;
152 wraps cleanly to its own line on narrow viewports instead of floating. */
153.repo-head { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:6px 16px; margin:24px 0 4px; }
154.repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; }
155.repo-title h1 { margin:0; }
156.repo-title .pill { font-size:11px; text-transform:uppercase; letter-spacing:.04em; align-self:center; }
157.repo-nav { font-size:13px; display:flex; align-items:baseline; gap:8px; color:var(--muted); }
158.repo-nav a { color:var(--muted); }
159.repo-nav a:hover { color:var(--accent); text-decoration:none; }
160.repo-nav .sep { color:var(--border); }
161.repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; }
162.repo-meta b { font-weight:600; color:var(--fg); }
163.pill-group { display:inline-flex; }
164.pill-group > .pill { border-radius:0; margin-left:-1px; position:relative; }
165.pill-group > .pill:first-child { border-radius:2em 0 0 2em; margin-left:0; }
166.pill-group > .pill:last-child { border-radius:0 2em 2em 0; }
167form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
168form.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; }
169form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
170.issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
171.issue-dot.open { background:#1a7f37; }
172.issue-dot.closed { background:#8250df; }
173.st.issue-open { background:#dafbe1; color:#1a7f37; }
174.st.issue-closed { background:#fbefff; color:#8250df; }
175.issue-post { margin:12px 0; }
176.issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
177.btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
178.readme { margin-top:16px; }
179.readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
180/* Kanban: cards are the only boxes. Columns are headers + whitespace, no
181 nested frames. */
182.kanban { display:flex; gap:20px; align-items:flex-start; overflow-x:auto; padding:4px 2px 8px; }
183.kanban .col { flex:1 1 0; min-width:240px; }
184.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); }
185.kanban .col h3 .count { font-weight:400; letter-spacing:0; text-transform:none; font-size:12px; margin-left:auto; }
186.kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:9px 12px; margin-bottom:8px; font-size:13px; line-height:1.45; box-shadow:0 1px 2px rgba(27,31,36,.05); }
187.kanban .card .title p { margin:0; font-weight:500; }
188.kanban .card.done .title { color:var(--muted); text-decoration:line-through; font-weight:400; }
189.kanban .card details { margin-top:7px; }
190.kanban .card summary { cursor:pointer; font-size:12px; color:var(--accent); list-style:none; }
191.kanban .card summary::-webkit-details-marker { display:none; }
192.kanban .card summary::before { content:"›"; display:inline-block; width:12px; transition:transform .15s ease; }
193.kanban .card details[open] summary::before { transform:rotate(90deg); }
194.kanban .card .card-details { padding:7px 0 1px; font-size:12px; color:var(--muted); }
195.kanban .card .card-details p { margin:0 0 5px; }
196.kanban .card .card-details ul { margin:4px 0; padding-left:16px; }
197.kanban .card .card-details > :last-child { margin-bottom:0; }
198.todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
199.todo-notes { margin:8px 2px; }
200.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
201.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; }
202.latest-commit + .box { border-radius:0 0 6px 6px; }
203.commit-list { list-style:none; padding:0; margin:0; }
204.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
205.commit-list li:first-child { border-top:0; }
206.sha { font:12px ui-monospace,monospace; color:var(--muted); }
207.file-diff { margin:16px 0; }
208.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; }
209.file-diff summary.head::-webkit-details-marker { display:none; }
210.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
211.file-diff[open] summary.head::before { content:"\25BE"; }
212.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
213.file-diff .stat { margin-left:auto; white-space:nowrap; }
214.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
215table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
216table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
217table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
218table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
219table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
220.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
221.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
222.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
223.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
224.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
225.log { background:#0d1117; color:#e6edf3; border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; }
226footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
227details.nav-menu { position:relative; }
228details.nav-menu > summary { list-style:none; cursor:pointer; color:var(--accent); font-size:14px; }
229details.nav-menu > summary::-webkit-details-marker { display:none; }
230details.nav-menu > summary::after { content:" ▾"; font-size:10px; color:var(--muted); }
231.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; }
232.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; }
233.nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); }
234.nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; }
235.nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
236.nav-dropdown a.current { font-weight:600; }
237details.rev-menu { display:inline-block; }
238details.rev-menu > summary .pill { cursor:pointer; }
239"#;
240
241/// Clipboard icon for the clone "copy" button.
242const CLIPBOARD_SVG: &str = r#"<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M10 1.5H6a.5.5 0 0 0-.5.5v1H4A1.5 1.5 0 0 0 2.5 4.5v9A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 12 3h-1.5V2a.5.5 0 0 0-.5-.5zm-3.5 1h3v1h-3v-1zM4 4.5h8v9H4v-9z"/></svg>"#;
243
244/// Filled folder icon for directory entries in the tree view.
245const FOLDER_SVG: &str = r#"<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>"#;
246
247/// Outline file icon for blob entries in the tree view.
248const FILE_SVG: &str = r#"<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>"#;
249
250/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
251/// Registered once on `document`, so it survives htmx body swaps.
252const CLONE_JS: &str = r#"
253(function(){
254 function copyText(t){
255 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
256 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
257 document.body.appendChild(ta); ta.focus(); ta.select();
258 try{document.execCommand('copy')}catch(e){}
259 document.body.removeChild(ta); return Promise.resolve();
260 }
261 document.addEventListener('click', function(e){
262 var nm=e.target.closest('details.nav-menu');
263 document.querySelectorAll('details.nav-menu').forEach(function(d){ if(d!==nm) d.removeAttribute('open'); });
264 var tab=e.target.closest('.clone-tab');
265 if(tab){
266 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
267 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
268 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
269 return;
270 }
271 var copy=e.target.closest('.copy-btn');
272 if(copy){
273 var box=copy.closest('.clone');
274 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
275 box.classList.add('copied');
276 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
277 });
278 }
279 });
280})();
281"#;
282
283/// Mount the web UI routes.
284pub fn routes(router: Router<App>) -> Router<App> {
285 router
286 .route("/", get(home))
287 .route("/-/settings", get(account_settings))
288 .route("/-/settings/keys", post(add_ssh_key))
289 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
290 .route("/-/new", get(new_repo_form).post(new_repo_submit))
291 .route("/{username}", get(user_profile))
292 .route(
293 "/{owner}/{repo}/settings",
294 get(repo_settings).post(repo_settings_submit),
295 )
296 .route("/{owner}/{repo}", get(repo_index))
297 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
298 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
299 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
300 .route("/{owner}/{repo}/commits/{rev}", get(commits))
301 .route("/{owner}/{repo}/commit/{id}", get(commit))
302 .route("/{owner}/{repo}/ci", get(ci_runs))
303 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
304 .route("/-/static/htmx.min.js", get(htmx_js))
305}
306
307/// Serve the vendored htmx script (embedded in the binary).
308async fn htmx_js() -> Response {
309 (
310 [(
311 header::CONTENT_TYPE,
312 "application/javascript; charset=utf-8",
313 )],
314 include_str!("../assets/htmx.min.js"),
315 )
316 .into_response()
317}
318
319pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
320 // Attach the session's CSRF token to every htmx request as a header, so any
321 // JS-driven action carries it without a hidden field. Omitted (no attribute)
322 // when unauthenticated. The token is hex, so it needs no JSON escaping.
323 let csrf = crate::auth::current_csrf();
324 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
325 html! {
326 (DOCTYPE)
327 html lang="en" {
328 head {
329 meta charset="utf-8";
330 meta name="viewport" content="width=device-width, initial-scale=1";
331 title { (title) " · anvil" }
332 style { (PreEscaped(STYLE)) }
333 }
334 body hx-boost="true" hx-headers=[hx_headers] {
335 header.top { div.container {
336 a.brand href="/" { "anvil" }
337 span style="margin-left:auto" {
338 @match user {
339 Some(u) => {
340 details.nav-menu {
341 summary { (u.username) }
342 div.nav-dropdown {
343 a href="/-/settings" { "Settings" }
344 form method="post" action="/-/logout" {
345 button type="submit" { "Sign out" }
346 }
347 }
348 }
349 }
350 None => { a href="/-/login" { "sign in" } }
351 }
352 }
353 } }
354 main { div.container { (body) } }
355 footer { div.container { "anvil — a git forge" } }
356 script src="/-/static/htmx.min.js" {}
357 script { (PreEscaped(CLONE_JS)) }
358 }
359 }
360 }
361}
362
363/// Hidden CSRF token field for embedding inside a mutating `<form>`.
364pub(crate) fn csrf_input(token: &str) -> Markup {
365 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
366}
367
368pub(crate) fn not_found(message: &str) -> Response {
369 (
370 StatusCode::NOT_FOUND,
371 layout(
372 "Not found",
373 None,
374 html! { h1 { "Not found" } p.muted { (message) } },
375 ),
376 )
377 .into_response()
378}
379
380pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
381 tracing::error!("ui error: {err}");
382 (
383 StatusCode::INTERNAL_SERVER_ERROR,
384 layout("Error", None, html! { h1 { "Something went wrong" } }),
385 )
386 .into_response()
387}
388
389/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
390/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
391pub(crate) async fn resolve_repo(
392 app: &App,
393 viewer: Option<&User>,
394 owner: &str,
395 name: &str,
396) -> Result<(PathBuf, Repository), Response> {
397 let owner_user = users::find_by_username(&app.db, owner)
398 .await
399 .map_err(server_error)?
400 .ok_or_else(|| not_found("no such user"))?;
401 let repo = repos::find(&app.db, owner_user.id, name)
402 .await
403 .map_err(server_error)?
404 .ok_or_else(|| not_found("no such repository"))?;
405 if !access::can_read(&repo, viewer) {
406 return Err(not_found("no such repository"));
407 }
408 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
409 if !path.exists() {
410 return Err(not_found("repository not found on disk"));
411 }
412 Ok((path, repo))
413}
414
415/// `GET /` — list repositories visible to the current user.
416async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
417 let all = repos::list_all_with_owner(&app.db)
418 .await
419 .map_err(server_error)?;
420 let repos: Vec<_> = all
421 .into_iter()
422 .filter(|r| {
423 !r.is_private
424 || user
425 .as_ref()
426 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
427 })
428 .collect();
429 Ok(layout(
430 "Repositories",
431 user.as_ref(),
432 html! {
433 div style="display:flex;align-items:center" {
434 h1 style="margin-right:auto" { "Repositories" }
435 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
436 }
437 @if repos.is_empty() {
438 p.muted {
439 "No repositories yet. "
440 @if user.is_some() { a href="/-/new" { "Create one" } "." }
441 @else { "Sign in to create one." }
442 }
443 } @else {
444 ul.repo-list {
445 @for r in &repos {
446 li {
447 div.name {
448 a href=(format!("/{}", r.owner)) { (r.owner) }
449 "/"
450 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
451 @if r.is_private { " " span.pill { "private" } }
452 }
453 @if !r.description.is_empty() { div.muted { (r.description) } }
454 }
455 }
456 }
457 }
458 },
459 ))
460}
461
462/// `GET /{username}` — a user's profile: their repositories (public to all;
463/// private only to themselves or an admin).
464async fn user_profile(
465 State(app): State<App>,
466 CurrentUser(viewer): CurrentUser,
467 Path(username): Path<String>,
468) -> Result<Markup, Response> {
469 let owner = users::find_by_username(&app.db, &username)
470 .await
471 .map_err(server_error)?
472 .ok_or_else(|| not_found("no such user"))?;
473 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
474 .await
475 .map_err(server_error)?
476 .into_iter()
477 .filter(|r| access::can_read(r, viewer.as_ref()))
478 .collect();
479 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
480
481 Ok(layout(
482 &owner.username,
483 viewer.as_ref(),
484 html! {
485 div style="display:flex;align-items:center" {
486 h1 style="margin-right:auto" { (owner.username) }
487 @if is_self { a.btn href="/-/new" { "New repository" } }
488 }
489 h2 { "Repositories" }
490 @if visible.is_empty() {
491 p.muted { "No repositories." }
492 } @else {
493 ul.repo-list {
494 @for r in &visible {
495 li {
496 div.name {
497 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
498 @if r.is_private { " " span.pill { "private" } }
499 }
500 @if !r.description.is_empty() { div.muted { (r.description) } }
501 }
502 }
503 }
504 }
505 },
506 ))
507}
508
509#[derive(serde::Deserialize)]
510struct AddKeyForm {
511 #[serde(default)]
512 title: String,
513 key: String,
514 #[serde(default)]
515 csrf: String,
516}
517
518/// `GET /settings` — account settings: profile + SSH keys.
519async fn account_settings(
520 State(app): State<App>,
521 CurrentUser(user): CurrentUser,
522 csrf: Csrf,
523) -> Response {
524 let Some(user) = user else {
525 return Redirect::to("/-/login").into_response();
526 };
527 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
528 Ok(keys) => keys,
529 Err(e) => return server_error(e),
530 };
531 account_page(&user, &keys, None, &csrf.0).into_response()
532}
533
534/// `POST /settings/keys` — register an SSH public key for the current user.
535async fn add_ssh_key(
536 State(app): State<App>,
537 CurrentUser(user): CurrentUser,
538 csrf: Csrf,
539 Form(form): Form<AddKeyForm>,
540) -> Response {
541 let Some(user) = user else {
542 return Redirect::to("/-/login").into_response();
543 };
544 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
545 return resp;
546 }
547 let result = match ssh_keys::parse_public_key(&form.key) {
548 Ok((fingerprint, content)) => {
549 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
550 .await
551 .map(|_| ())
552 }
553 Err(e) => Err(e),
554 };
555 match result {
556 Ok(()) => Redirect::to("/-/settings").into_response(),
557 Err(e) => {
558 let keys = ssh_keys::list_by_user(&app.db, user.id)
559 .await
560 .unwrap_or_default();
561 (
562 StatusCode::BAD_REQUEST,
563 account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
564 )
565 .into_response()
566 }
567 }
568}
569
570/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
571async fn delete_ssh_key(
572 State(app): State<App>,
573 CurrentUser(user): CurrentUser,
574 csrf: Csrf,
575 Path(id): Path<i64>,
576 Form(form): Form<crate::auth::CsrfForm>,
577) -> Response {
578 let Some(user) = user else {
579 return Redirect::to("/-/login").into_response();
580 };
581 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
582 return resp;
583 }
584 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
585 return server_error(e);
586 }
587 Redirect::to("/-/settings").into_response()
588}
589
590fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
591 layout(
592 "Account settings",
593 Some(user),
594 html! {
595 h1 { "Account settings" }
596 p.muted {
597 "Signed in as " strong { (user.username) }
598 @if !user.email.is_empty() { " · " (user.email) }
599 }
600
601 h2 { "SSH keys" }
602 p.muted { "Add a public key to clone and push over SSH." }
603 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
604 @if keys.is_empty() {
605 p.muted { "No SSH keys yet." }
606 } @else {
607 div.box {
608 @for k in keys {
609 div.row {
610 div {
611 @if !k.title.is_empty() { strong { (k.title) } " " }
612 span.sha { (k.fingerprint) }
613 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
614 }
615 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
616 (csrf_input(csrf))
617 button.linkbtn type="submit" { "delete" }
618 }
619 }
620 }
621 }
622 }
623
624 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
625 (csrf_input(csrf))
626 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
627 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
628 p { button.btn type="submit" { "Add SSH key" } }
629 }
630 },
631 )
632}
633
634fn forbidden() -> Response {
635 (
636 StatusCode::FORBIDDEN,
637 layout(
638 "Forbidden",
639 None,
640 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
641 ),
642 )
643 .into_response()
644}
645
646#[derive(serde::Deserialize)]
647struct NewRepoForm {
648 name: String,
649 #[serde(default)]
650 description: String,
651 private: Option<String>,
652 #[serde(default)]
653 csrf: String,
654}
655
656#[derive(serde::Deserialize)]
657struct SettingsForm {
658 #[serde(default)]
659 description: String,
660 private: Option<String>,
661 #[serde(default)]
662 mirror_url: String,
663 #[serde(default)]
664 csrf: String,
665}
666
667/// `GET /new` — new-repository form (requires login).
668async fn new_repo_form(
669 State(app): State<App>,
670 CurrentUser(user): CurrentUser,
671 csrf: Csrf,
672) -> Response {
673 let Some(user) = user else {
674 return Redirect::to("/-/login").into_response();
675 };
676 let remote = push_remote_url(&app, &user.username, "");
677 new_repo_page(&user, None, "", "", false, &remote, &csrf.0).into_response()
678}
679
680/// The remote URL to suggest for push-to-create: SSH when enabled (pushes
681/// without a credential prompt), otherwise HTTP. `name` may be empty, in which
682/// case a `<name>` placeholder is used.
683fn push_remote_url(app: &App, owner: &str, name: &str) -> String {
684 let name = if name.is_empty() { "<name>" } else { name };
685 if app.config.ssh.enabled {
686 app.config.ssh_clone_url(owner, name)
687 } else {
688 app.config.http_clone_url(owner, name)
689 }
690}
691
692/// `POST /new` — create a repository owned by the current user.
693async fn new_repo_submit(
694 State(app): State<App>,
695 CurrentUser(user): CurrentUser,
696 csrf: Csrf,
697 Form(form): Form<NewRepoForm>,
698) -> Response {
699 let Some(user) = user else {
700 return Redirect::to("/-/login").into_response();
701 };
702 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
703 return resp;
704 }
705 let private = form.private.is_some();
706 match repos::create(
707 &app.db,
708 &app.config.repositories_dir(),
709 &user,
710 &form.name,
711 &form.description,
712 private,
713 )
714 .await
715 {
716 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
717 Err(e) => {
718 let remote = push_remote_url(&app, &user.username, &form.name);
719 (
720 StatusCode::BAD_REQUEST,
721 new_repo_page(
722 &user,
723 Some(&e.to_string()),
724 &form.name,
725 &form.description,
726 private,
727 &remote,
728 &csrf.0,
729 ),
730 )
731 .into_response()
732 }
733 }
734}
735
736fn new_repo_page(
737 user: &User,
738 error: Option<&str>,
739 name: &str,
740 description: &str,
741 private: bool,
742 remote: &str,
743 csrf: &str,
744) -> Markup {
745 layout(
746 "New repository",
747 Some(user),
748 html! {
749 h1 { "New repository" }
750 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
751 form.stack method="post" action="/-/new" {
752 (csrf_input(csrf))
753 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
754 p { label { "Description" br; input type="text" name="description" value=(description); } }
755 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
756 p { button.btn type="submit" { "Create repository" } }
757 }
758 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
759
760 h2 { "…or push an existing repository" }
761 p.muted { "Pushing to a name that doesn't exist yet creates the repository (private). No need for the form above." }
762 pre.cmds { (format!("git remote add origin {remote}\ngit push -u origin main")) }
763 },
764 )
765}
766
767/// Load a repo for an owner-only settings action, enforcing write access.
768async fn resolve_for_settings(
769 app: &App,
770 viewer: Option<&User>,
771 owner: &str,
772 name: &str,
773) -> Result<Repository, Response> {
774 let owner_user = users::find_by_username(&app.db, owner)
775 .await
776 .map_err(server_error)?
777 .ok_or_else(|| not_found("no such repository"))?;
778 let repo = repos::find(&app.db, owner_user.id, name)
779 .await
780 .map_err(server_error)?
781 .ok_or_else(|| not_found("no such repository"))?;
782 if !access::can_read(&repo, viewer) {
783 return Err(not_found("no such repository"));
784 }
785 if !access::can_write(&repo, viewer) {
786 return Err(forbidden());
787 }
788 Ok(repo)
789}
790
791/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
792async fn repo_settings(
793 State(app): State<App>,
794 CurrentUser(user): CurrentUser,
795 csrf: Csrf,
796 Path((owner, repo)): Path<(String, String)>,
797) -> Response {
798 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
799 Ok(m) => m,
800 Err(resp) => return resp,
801 };
802 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
803}
804
805/// `POST /{owner}/{repo}/settings` — update description / visibility.
806async fn repo_settings_submit(
807 State(app): State<App>,
808 CurrentUser(user): CurrentUser,
809 csrf: Csrf,
810 Path((owner, repo)): Path<(String, String)>,
811 Form(form): Form<SettingsForm>,
812) -> Response {
813 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
814 Ok(m) => m,
815 Err(resp) => return resp,
816 };
817 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
818 return resp;
819 }
820 if let Err(e) = repos::update_settings(
821 &app.db,
822 meta.id,
823 &form.description,
824 form.private.is_some(),
825 &form.mirror_url,
826 )
827 .await
828 {
829 return server_error(e);
830 }
831 Redirect::to(&format!("/{owner}/{repo}")).into_response()
832}
833
834fn settings_page(
835 user: Option<&User>,
836 owner: &str,
837 repo: &str,
838 meta: &Repository,
839 error: Option<&str>,
840 csrf: &str,
841) -> Markup {
842 layout(
843 &format!("{owner}/{repo}: settings"),
844 user,
845 html! {
846 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
847 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
848 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
849 (csrf_input(csrf))
850 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
851 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
852 p {
853 label {
854 "Mirror push URL" br;
855 input type="text" name="mirror_url" value=(meta.mirror_url)
856 placeholder="https://x-access-token:<token>@github.com/you/repo.git";
857 }
858 br;
859 span.muted style="font-size:12px" {
860 "After every push here, all refs are mirrored to this remote ("
861 code { "git push --mirror" }
862 "). Stored as-is — use a scoped token. Empty disables it."
863 }
864 }
865 p { button.btn type="submit" { "Save changes" } }
866 }
867 },
868 )
869}
870
871fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
872 let http = app.config.http_clone_url(owner, name);
873 let ssh = app
874 .config
875 .ssh
876 .enabled
877 .then(|| app.config.ssh_clone_url(owner, name));
878 // SSH first and preselected when available — it's the protocol that can
879 // push without a credential prompt.
880 let default_cmd = format!("git clone {}", ssh.as_deref().unwrap_or(&http));
881 html! {
882 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
883 div.clone-head {
884 span.muted { "Clone" }
885 div.clone-tabs {
886 @if ssh.is_some() {
887 button.clone-tab.active type="button" data-proto="ssh" { "SSH" }
888 button.clone-tab type="button" data-proto="http" { "HTTP" }
889 } @else {
890 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
891 }
892 }
893 }
894 div.clone-cmd {
895 code { (default_cmd) }
896 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
897 (PreEscaped(CLIPBOARD_SVG))
898 }
899 span.copied-msg { "Copied!" }
900 }
901 }
902 }
903}
904
905/// `GET /{owner}/{repo}` — repository overview with the root tree.
906async fn repo_index(
907 State(app): State<App>,
908 CurrentUser(user): CurrentUser,
909 Path((owner, repo)): Path<(String, String)>,
910) -> Result<Markup, Response> {
911 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
912 let overview = browse::overview(&path).map_err(server_error)?;
913
914 let can_write = access::can_write(&meta, user.as_ref());
915 let header = html! {
916 div.repo-head {
917 span.repo-title {
918 h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) }
919 @if meta.is_private { span.pill { "private" } }
920 }
921 nav.repo-nav {
922 a href=(format!("/{owner}/{repo}/issues")) { "Issues" }
923 span.sep { "·" }
924 a href=(format!("/{owner}/{repo}/ci")) { "CI" }
925 span.sep { "·" }
926 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
927 @if can_write {
928 span.sep { "·" }
929 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
930 }
931 }
932 }
933 @if !meta.description.is_empty() { p.muted { (meta.description) } }
934 p.repo-meta {
935 span { b { (overview.branches.len()) } " " (plural(overview.branches.len(), "branch", "branches")) }
936 span { b { (overview.tags.len()) } " " (plural(overview.tags.len(), "tag", "tags")) }
937 }
938 (clone_box(&app, &owner, &repo))
939 };
940
941 if overview.is_empty {
942 return Ok(layout(
943 &format!("{owner}/{repo}"),
944 user.as_ref(),
945 html! {
946 (header)
947 p.muted { "This repository is empty. Push to it to get started." }
948 },
949 ));
950 }
951
952 let rev = overview
953 .default_branch
954 .clone()
955 .unwrap_or_else(|| "HEAD".to_string());
956 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
957 let latest = browse::commit_log(&path, &rev, 1)
958 .map_err(server_error)?
959 .into_iter()
960 .next();
961 // Best-effort: a failed walk only costs the per-entry annotations.
962 let entry_commits =
963 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
964
965 // A root README renders below the tree, GitHub-style. Best-effort: a
966 // missing or unreadable file just omits the section.
967 let readme = entries
968 .iter()
969 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("readme.md"))
970 .and_then(|e| {
971 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
972 Some((
973 render_markdown(&String::from_utf8_lossy(&bytes)),
974 e.name.clone(),
975 ))
976 });
977
978 // A root TODO.md with tasks renders as a kanban board below the README.
979 let todo_board = entries
980 .iter()
981 .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
982 .and_then(|e| {
983 let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
984 let board = todomd::render_board(&String::from_utf8_lossy(&bytes))?;
985 Some((board, e.name.clone()))
986 });
987
988 Ok(layout(
989 &format!("{owner}/{repo}"),
990 user.as_ref(),
991 html! {
992 (header)
993 p {
994 (rev_switcher(&owner, &repo, &rev, &overview))
995 " · "
996 a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" }
997 }
998 @if let Some(c) = &latest {
999 div.latest-commit {
1000 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1001 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
1002 span.muted style="margin-left:auto" {
1003 (c.author) " · "
1004 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1005 }
1006 }
1007 }
1008 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
1009 @if let Some((rendered, name)) = &readme {
1010 div.box.readme {
1011 div.readme-head {
1012 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1013 }
1014 div.md-body { (rendered) }
1015 }
1016 }
1017 @if let Some((board, name)) = &todo_board {
1018 p.todo-board-head {
1019 a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
1020 }
1021 (board)
1022 }
1023 },
1024 ))
1025}
1026
1027async fn tree_root(
1028 State(app): State<App>,
1029 user: CurrentUser,
1030 Path((owner, repo, rev)): Path<(String, String, String)>,
1031) -> Result<Markup, Response> {
1032 render_tree(&app, user, &owner, &repo, &rev, "").await
1033}
1034
1035async fn tree_path(
1036 State(app): State<App>,
1037 user: CurrentUser,
1038 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1039) -> Result<Markup, Response> {
1040 render_tree(&app, user, &owner, &repo, &rev, &path).await
1041}
1042
1043async fn render_tree(
1044 app: &App,
1045 CurrentUser(user): CurrentUser,
1046 owner: &str,
1047 repo: &str,
1048 rev: &str,
1049 path: &str,
1050) -> Result<Markup, Response> {
1051 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
1052 let overview = browse::overview(&repo_path).map_err(server_error)?;
1053 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
1054 // Best-effort: a failed walk only costs the per-entry annotations.
1055 let entry_commits =
1056 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
1057 Ok(layout(
1058 &format!("{owner}/{repo}: {path}"),
1059 user.as_ref(),
1060 html! {
1061 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1062 p { (rev_switcher(owner, repo, rev, &overview)) }
1063 (breadcrumbs(owner, repo, rev, path, false))
1064 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
1065 },
1066 ))
1067}
1068
1069/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
1070/// by default; `?plain=1` shows the raw source (toggle links on the page).
1071async fn blob(
1072 State(app): State<App>,
1073 CurrentUser(user): CurrentUser,
1074 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
1075 Query(query): Query<HashMap<String, String>>,
1076) -> Result<Markup, Response> {
1077 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1078 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
1079 .map_err(server_error)?
1080 .ok_or_else(|| not_found("file not found"))?;
1081
1082 let markdown = is_markdown(&path) && !is_binary(&bytes);
1083 // Custom renderers for well-known filenames (the plugin point — add new
1084 // filename → renderer pairs here). TODO.md defaults to a kanban board.
1085 let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes);
1086 let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md"))
1087 .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes)))
1088 .flatten();
1089 let rendered = markdown && !query.contains_key("plain") && board.is_none();
1090
1091 let body = if let Some(board) = &board {
1092 board.clone()
1093 } else if is_binary(&bytes) {
1094 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
1095 } else if rendered {
1096 let text = String::from_utf8_lossy(&bytes);
1097 html! { div.md-body { (render_markdown(&text)) } }
1098 } else {
1099 let text = String::from_utf8_lossy(&bytes);
1100 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
1101 let lines = cached_highlight(budget, &oid, &path, &text);
1102 html! {
1103 table.code {
1104 @for (i, line) in lines.iter().enumerate() {
1105 tr {
1106 td.ln { (i + 1) }
1107 td { (PreEscaped(line)) }
1108 }
1109 }
1110 }
1111 }
1112 };
1113
1114 let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
1115 Ok(layout(
1116 &format!("{owner}/{repo}: {path}"),
1117 user.as_ref(),
1118 html! {
1119 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
1120 (breadcrumbs(&owner, &repo, &rev, &path, true))
1121 @if markdown {
1122 p.view-toggle {
1123 span.pill-group {
1124 @if is_todo {
1125 @if board.is_some() { span.pill.active { "Board" } }
1126 @else { a.pill href=(&blob_url) { "Board" } }
1127 @if rendered { span.pill.active { "Rendered" } }
1128 @else { a.pill href=(format!("{blob_url}?md=1")) { "Rendered" } }
1129 } @else if rendered {
1130 span.pill.active { "Rendered" }
1131 } @else {
1132 a.pill href=(&blob_url) { "Rendered" }
1133 }
1134 @if rendered || board.is_some() {
1135 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
1136 } @else {
1137 span.pill.active { "Source" }
1138 }
1139 }
1140 }
1141 }
1142 @if board.is_some() {
1143 // The board supplies its own column structure; an enclosing
1144 // box would just nest frames.
1145 (body)
1146 } @else {
1147 div.box style="overflow-x:auto" { (body) }
1148 }
1149 },
1150 ))
1151}
1152
1153/// Pick the singular or plural noun for a count (`1 branch` / `2 branches`).
1154fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
1155 if n == 1 { one } else { many }
1156}
1157
1158/// Whether a path should be treated as markdown (by extension).
1159fn is_markdown(path: &str) -> bool {
1160 std::path::Path::new(path)
1161 .extension()
1162 .and_then(|e| e.to_str())
1163 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
1164}
1165
1166/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
1167///
1168/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
1169/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
1170/// link and image destinations are dropped.
1171pub(crate) fn render_markdown(text: &str) -> Markup {
1172 use pulldown_cmark::{
1173 Event,
1174 Options,
1175 Parser,
1176 Tag,
1177 html,
1178 };
1179
1180 fn safe_url(dest: &str) -> bool {
1181 let d = dest.trim().to_ascii_lowercase();
1182 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
1183 }
1184
1185 let opts = Options::ENABLE_TABLES
1186 | Options::ENABLE_STRIKETHROUGH
1187 | Options::ENABLE_TASKLISTS
1188 | Options::ENABLE_FOOTNOTES;
1189 let events = Parser::new_ext(text, opts).map(|ev| match ev {
1190 Event::Html(h) => Event::Text(h),
1191 Event::InlineHtml(h) => Event::Text(h),
1192 Event::Start(Tag::Link {
1193 link_type,
1194 dest_url,
1195 title,
1196 id,
1197 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
1198 link_type,
1199 dest_url: "".into(),
1200 title,
1201 id,
1202 }),
1203 Event::Start(Tag::Image {
1204 link_type,
1205 dest_url,
1206 title,
1207 id,
1208 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
1209 link_type,
1210 dest_url: "".into(),
1211 title,
1212 id,
1213 }),
1214 e => e,
1215 });
1216 let mut out = String::new();
1217 html::push_html(&mut out, events);
1218 PreEscaped(out)
1219}
1220
1221/// How far back the per-entry "latest commit" walk looks. Entries last touched
1222/// beyond this many commits just lose the annotation.
1223const ENTRY_LOG_WALK: usize = 400;
1224
1225/// Folder or file icon for an entry row (tree listings, pages, artifacts).
1226pub(crate) fn entry_icon(is_dir: bool) -> Markup {
1227 html! {
1228 @if is_dir {
1229 span.icon.dir { (PreEscaped(FOLDER_SVG)) }
1230 } @else {
1231 span.icon { (PreEscaped(FILE_SVG)) }
1232 }
1233 }
1234}
1235
1236/// Human-readable byte size (`482 B`, `1.2 KiB`, `34.0 MiB`).
1237pub(crate) fn fmt_size(bytes: i64) -> String {
1238 let b = bytes.max(0) as f64;
1239 match b {
1240 b if b < 1024.0 => format!("{bytes} B"),
1241 b if b < 1024.0 * 1024.0 => format!("{:.1} KiB", b / 1024.0),
1242 b if b < 1024.0 * 1024.0 * 1024.0 => format!("{:.1} MiB", b / (1024.0 * 1024.0)),
1243 b => format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0)),
1244 }
1245}
1246
1247/// Percent-encode a ref name for use as one path segment in a URL. Axum
1248/// matches routes before decoding, so an encoded `/` keeps a branch like
1249/// `feat/x` inside the single `{rev}` segment.
1250pub(crate) fn enc_ref(name: &str) -> String {
1251 name.replace('%', "%25")
1252 .replace('/', "%2F")
1253 .replace('?', "%3F")
1254 .replace('#', "%23")
1255}
1256
1257/// Branch/tag switcher: a dropdown over the current rev linking each ref to
1258/// its tree view. Branch names, tag names, and commit ids all work as `rev`.
1259fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup {
1260 html! {
1261 details.nav-menu.rev-menu {
1262 summary { span.pill { (rev) } }
1263 div.nav-dropdown.left {
1264 @if !overview.branches.is_empty() {
1265 div.dd-head { "Branches" }
1266 @for b in &overview.branches {
1267 a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) }
1268 }
1269 }
1270 @if !overview.tags.is_empty() {
1271 div.dd-head { "Tags" }
1272 @for t in &overview.tags {
1273 a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) }
1274 }
1275 }
1276 }
1277 }
1278 }
1279}
1280
1281/// Render a tree listing as a box of rows; directories link to `tree`, files to
1282/// `blob`. Each entry also shows the subject of (and links to) the latest
1283/// commit that touched it, when `latest` has one for it.
1284fn tree_table(
1285 owner: &str,
1286 repo: &str,
1287 rev: &str,
1288 path: &str,
1289 entries: &[browse::TreeEntry],
1290 latest: &BTreeMap<String, browse::CommitInfo>,
1291) -> Markup {
1292 let join = |name: &str| {
1293 if path.is_empty() {
1294 name.to_string()
1295 } else {
1296 format!("{path}/{name}")
1297 }
1298 };
1299 html! {
1300 div.box {
1301 @if !path.is_empty() {
1302 div.row {
1303 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
1304 }
1305 }
1306 @for e in entries {
1307 @let child = join(&e.name);
1308 @let kind = if e.is_dir { "tree" } else { "blob" };
1309 div.row {
1310 a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
1311 (entry_icon(e.is_dir))
1312 (e.name) @if e.is_dir { "/" }
1313 }
1314 @if let Some(c) = latest.get(&e.name) {
1315 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
1316 span.fc-time title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1317 }
1318 }
1319 }
1320 }
1321 }
1322}
1323
1324fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
1325 match path.rsplit_once('/') {
1326 Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)),
1327 None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)),
1328 }
1329}
1330
1331/// Path breadcrumbs. `is_blob` marks the final component as a file.
1332fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
1333 // Precompute (label, cumulative_path) for each path component.
1334 let mut crumbs: Vec<(String, String)> = Vec::new();
1335 let mut acc = String::new();
1336 for part in path.split('/').filter(|p| !p.is_empty()) {
1337 if !acc.is_empty() {
1338 acc.push('/');
1339 }
1340 acc.push_str(part);
1341 crumbs.push((part.to_string(), acc.clone()));
1342 }
1343 let last = crumbs.len();
1344 html! {
1345 div.crumbs {
1346 a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
1347 @for (i, (label, cum)) in crumbs.iter().enumerate() {
1348 " / "
1349 @if i + 1 == last && is_blob {
1350 span { (label) }
1351 } @else {
1352 a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
1353 }
1354 }
1355 }
1356 }
1357}
1358
1359/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
1360async fn commits(
1361 State(app): State<App>,
1362 CurrentUser(user): CurrentUser,
1363 Path((owner, repo, rev)): Path<(String, String, String)>,
1364) -> Result<Markup, Response> {
1365 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1366 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
1367
1368 // Map each commit oid to its latest run status, for inline badges. One query
1369 // for the repo's recent runs; first match wins (list is newest-first).
1370 let runs = ci::list_by_repo(&app.db, meta.id, 200)
1371 .await
1372 .unwrap_or_default();
1373 let mut status_of: HashMap<&str, &str> = HashMap::new();
1374 for r in &runs {
1375 status_of
1376 .entry(r.commit.as_str())
1377 .or_insert(r.status.as_str());
1378 }
1379
1380 Ok(layout(
1381 &format!("{owner}/{repo}: commits"),
1382 user.as_ref(),
1383 html! {
1384 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
1385 ul.commit-list {
1386 @for c in &log {
1387 li {
1388 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1389 @if let Some(st) = status_of.get(c.id.as_str()) {
1390 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
1391 }
1392 span { (c.summary) }
1393 span.muted style="margin-left:auto" {
1394 (c.author) " · "
1395 span title=(fmt_time(c.time)) { (fmt_relative(c.time)) }
1396 }
1397 }
1398 }
1399 }
1400 },
1401 ))
1402}
1403
1404/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
1405async fn commit(
1406 State(app): State<App>,
1407 CurrentUser(user): CurrentUser,
1408 Path((owner, repo, id)): Path<(String, String, String)>,
1409) -> Result<Markup, Response> {
1410 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1411 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
1412 Ok(layout(
1413 &format!("{owner}/{repo}: {}", detail.info.short),
1414 user.as_ref(),
1415 html! {
1416 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
1417 p { (detail.info.summary) }
1418 p.muted {
1419 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
1420 span.sha { (detail.info.id) }
1421 @if let Some(parent) = &detail.parent {
1422 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
1423 }
1424 " · "
1425 a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
1426 }
1427 @if detail.changes.is_empty() {
1428 p.muted { "No file changes." }
1429 }
1430 @for change in &detail.changes {
1431 (render_file_diff(change))
1432 }
1433 },
1434 ))
1435}
1436
1437/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
1438async fn ci_runs(
1439 State(app): State<App>,
1440 CurrentUser(user): CurrentUser,
1441 Path((owner, repo)): Path<(String, String)>,
1442) -> Result<Markup, Response> {
1443 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1444 let runs = ci::list_by_repo(&app.db, meta.id, 100)
1445 .await
1446 .map_err(server_error)?;
1447 Ok(layout(
1448 &format!("{owner}/{repo}: CI"),
1449 user.as_ref(),
1450 html! {
1451 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
1452 @if runs.is_empty() {
1453 p.muted {
1454 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
1455 " pipeline and push to trigger one."
1456 }
1457 } @else {
1458 div.box {
1459 @for r in &runs {
1460 div.row {
1461 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
1462 (status_badge(&r.status))
1463 span.sha { (short_commit(&r.commit)) }
1464 span { (r.ref_name) }
1465 }
1466 span.muted { (fmt_time(r.created_at)) }
1467 }
1468 }
1469 }
1470 }
1471 },
1472 ))
1473}
1474
1475/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1476async fn ci_run(
1477 State(app): State<App>,
1478 CurrentUser(user): CurrentUser,
1479 Path((owner, repo, id)): Path<(String, String, i64)>,
1480) -> Result<Markup, Response> {
1481 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1482 let run = ci::get(&app.db, id)
1483 .await
1484 .map_err(server_error)?
1485 .filter(|r| r.repo_id == meta.id)
1486 .ok_or_else(|| not_found("no such CI run"))?;
1487 let artifacts = ci::artifacts_for_run(&app.db, run.id)
1488 .await
1489 .map_err(server_error)?;
1490 Ok(layout(
1491 &format!("{owner}/{repo}: CI #{}", run.id),
1492 user.as_ref(),
1493 html! {
1494 h1 {
1495 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1496 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1497 " · #" (run.id)
1498 }
1499 p {
1500 (status_badge(&run.status))
1501 " "
1502 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1503 " " span.muted { (run.ref_name) }
1504 }
1505 p.muted {
1506 "queued " (fmt_time(run.created_at))
1507 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1508 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1509 @if let Some(d) = run_duration(&run) { " · took " (d) }
1510 }
1511 @if !artifacts.is_empty() {
1512 h2 { "Artifacts" }
1513 div.box {
1514 @for a in &artifacts {
1515 div.row {
1516 a.entry href=(format!("/{owner}/{repo}/ci/{}/artifacts/{}", run.id, a.name)) {
1517 (entry_icon(a.is_dir))
1518 (a.name)
1519 @if a.browse { " " span.pill { "site" } }
1520 @else if a.is_dir { ".tar.gz" }
1521 }
1522 span.muted {
1523 (artifact_meta_chips(&a.meta))
1524 (fmt_size(a.size))
1525 }
1526 }
1527 }
1528 }
1529 }
1530 @if run.log.is_empty() {
1531 p.muted { "No output yet." }
1532 } @else {
1533 pre.log { (run.log) }
1534 }
1535 },
1536 ))
1537}
1538
1539/// Render an artifact's extractor metadata (a JSON object of key → value) as
1540/// inline `key: value` chips before the size.
1541fn artifact_meta_chips(meta: &str) -> Markup {
1542 let map: BTreeMap<String, String> = serde_json::from_str(meta).unwrap_or_default();
1543 html! {
1544 @for (k, v) in &map {
1545 span.pill title=(k) { (k) ": " (v) }
1546 " "
1547 }
1548 }
1549}
1550
1551/// A coloured status pill for a CI run status string.
1552fn status_badge(status: &str) -> Markup {
1553 html! { span class=(format!("st {status}")) { (status) } }
1554}
1555
1556/// First 8 hex chars of a commit oid (for compact display).
1557fn short_commit(commit: &str) -> &str {
1558 &commit[..commit.len().min(8)]
1559}
1560
1561/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
1562fn run_duration(run: &CiRun) -> Option<String> {
1563 if run.started_at > 0 && run.finished_at >= run.started_at {
1564 Some(format!("{}s", run.finished_at - run.started_at))
1565 } else {
1566 None
1567 }
1568}
1569
1570/// Render one file's diff (added/deleted/modified) as a unified line diff.
1571/// A file diff bigger than this many rows starts collapsed (its header still
1572/// shows the +/− counts; clicking expands it — native `details`, no JS).
1573const DIFF_COLLAPSE_ROWS: usize = 400;
1574
1575fn render_file_diff(change: &FileChange) -> Markup {
1576 let (badge_cls, badge) = match change.kind {
1577 ChangeKind::Added => ("add", "added"),
1578 ChangeKind::Deleted => ("del", "deleted"),
1579 ChangeKind::Modified => ("mod", "modified"),
1580 };
1581 let head = |stat: Markup| {
1582 html! {
1583 summary.head {
1584 span class=(format!("badge {badge_cls}")) { (badge) }
1585 span { (change.path) }
1586 span.stat { (stat) }
1587 }
1588 }
1589 };
1590
1591 let binary = change.old.as_deref().is_some_and(is_binary)
1592 || change.new.as_deref().is_some_and(is_binary);
1593 if binary {
1594 return html! {
1595 details.file-diff open {
1596 (head(html! { span.muted { "binary" } }))
1597 div.box { div.row { span.muted { "Binary file" } } }
1598 }
1599 };
1600 }
1601
1602 let old = change
1603 .old
1604 .as_deref()
1605 .map(|b| String::from_utf8_lossy(b).into_owned())
1606 .unwrap_or_default();
1607 let new = change
1608 .new
1609 .as_deref()
1610 .map(|b| String::from_utf8_lossy(b).into_owned())
1611 .unwrap_or_default();
1612 let diff = TextDiff::from_lines(&old, &new);
1613 let (mut adds, mut dels) = (0usize, 0usize);
1614 for c in diff.iter_all_changes() {
1615 match c.tag() {
1616 ChangeTag::Insert => adds += 1,
1617 ChangeTag::Delete => dels += 1,
1618 ChangeTag::Equal => {}
1619 }
1620 }
1621 // Hunks: changed lines plus 3 lines of context, not the whole file.
1622 let groups = diff.grouped_ops(3);
1623 let rendered_rows: usize = groups
1624 .iter()
1625 .flatten()
1626 .map(|op| diff.iter_changes(op).count())
1627 .sum();
1628
1629 html! {
1630 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
1631 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
1632 (diff_table(&diff, &groups, old.lines().count()))
1633 }
1634 }
1635}
1636
1637/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
1638/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
1639/// (including before the first hunk and after the last).
1640fn diff_table<'a>(
1641 diff: &TextDiff<'a, 'a, '_, str>,
1642 groups: &[Vec<similar::DiffOp>],
1643 old_total: usize,
1644) -> Markup {
1645 let gap_row = |n: usize| {
1646 html! {
1647 @if n > 0 {
1648 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
1649 }
1650 }
1651 };
1652 // Unchanged-line gap before each group, and after the last one.
1653 let mut prev_end = 0usize; // end of the previous group, in old-file lines
1654 let mut with_gaps = Vec::with_capacity(groups.len());
1655 for group in groups {
1656 let start = group.first().map_or(prev_end, |op| op.old_range().start);
1657 with_gaps.push((start.saturating_sub(prev_end), group));
1658 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
1659 }
1660 let trailing = old_total.saturating_sub(prev_end);
1661
1662 html! {
1663 table.code.diff {
1664 @for (gap, group) in &with_gaps {
1665 (gap_row(*gap))
1666 @for op in group.iter() {
1667 @for change in diff.iter_changes(op) {
1668 @let (sign, cls) = match change.tag() {
1669 ChangeTag::Delete => ("-", "del"),
1670 ChangeTag::Insert => ("+", "ins"),
1671 ChangeTag::Equal => (" ", ""),
1672 };
1673 tr class=(cls) {
1674 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
1675 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
1676 td.sign { (sign) }
1677 td { (change.value().trim_end_matches('\n')) }
1678 }
1679 }
1680 }
1681 }
1682 (gap_row(trailing))
1683 }
1684 }
1685}
1686
1687/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
1688fn highlighter() -> &'static (SyntaxSet, Theme) {
1689 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
1690 HL.get_or_init(|| {
1691 let syntaxes = SyntaxSet::load_defaults_newlines();
1692 let themes = ThemeSet::load_defaults();
1693 let theme = themes
1694 .themes
1695 .get("InspiredGitHub")
1696 .or_else(|| themes.themes.values().next())
1697 .cloned()
1698 .expect("at least one default theme");
1699 (syntaxes, theme)
1700 })
1701}
1702
1703/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
1704/// blob's rendered HTML is immutable for its object id (the extension is part
1705/// of the key because it picks the syntax), so each file is highlighted once
1706/// rather than once per request — highlighting large files is by far the most
1707/// expensive thing a page view can do. The budget is
1708/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
1709/// RAM-constrained hosts). Concurrent misses may both compute and the last
1710/// insert wins; that's benign.
1711fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
1712 if budget_bytes == 0 {
1713 return Arc::new(highlight(path, text));
1714 }
1715 struct Cache {
1716 lru: lru::LruCache<String, Arc<Vec<String>>>,
1717 bytes: usize,
1718 }
1719 fn cost(key: &str, lines: &[String]) -> usize {
1720 key.len() + lines.iter().map(String::len).sum::<usize>()
1721 }
1722 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
1723 let cache = CACHE.get_or_init(|| {
1724 Mutex::new(Cache {
1725 lru: lru::LruCache::unbounded(),
1726 bytes: 0,
1727 })
1728 });
1729
1730 let ext = std::path::Path::new(path)
1731 .extension()
1732 .and_then(|e| e.to_str())
1733 .unwrap_or("");
1734 let key = format!("{oid}\x00{ext}");
1735 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
1736 return hit.clone();
1737 }
1738
1739 let lines = Arc::new(highlight(path, text));
1740 let mut c = cache.lock().expect("cache lock");
1741 c.bytes += cost(&key, &lines);
1742 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
1743 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
1744 }
1745 // Evict oldest entries until we're back under budget. An entry larger than
1746 // the whole budget evicts itself — memory stays bounded, it just never caches.
1747 while c.bytes > budget_bytes {
1748 let Some((k, v)) = c.lru.pop_lru() else { break };
1749 c.bytes -= cost(&k, &v);
1750 }
1751 lines
1752}
1753
1754/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1755/// Falls back to escaped plain text for large files or on any failure.
1756fn highlight(path: &str, text: &str) -> Vec<String> {
1757 if text.len() > 512 * 1024 {
1758 return text.lines().map(escape).collect();
1759 }
1760 let (syntaxes, theme) = highlighter();
1761 let syntax = std::path::Path::new(path)
1762 .extension()
1763 .and_then(|e| e.to_str())
1764 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1765 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1766 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1767
1768 let mut h = HighlightLines::new(syntax, theme);
1769 text.lines()
1770 .map(|line| match h.highlight_line(line, syntaxes) {
1771 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1772 .unwrap_or_else(|_| escape(line)),
1773 Err(_) => escape(line),
1774 })
1775 .collect()
1776}
1777
1778fn escape(s: &str) -> String {
1779 s.replace('&', "&amp;")
1780 .replace('<', "&lt;")
1781 .replace('>', "&gt;")
1782}
1783
1784/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1785pub(crate) fn fmt_time(secs: i64) -> String {
1786 match OffsetDateTime::from_unix_timestamp(secs) {
1787 Ok(t) => format!(
1788 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1789 t.year(),
1790 u8::from(t.month()),
1791 t.day(),
1792 t.hour(),
1793 t.minute()
1794 ),
1795 Err(_) => secs.to_string(),
1796 }
1797}
1798
1799/// Format a Unix timestamp relative to now (`2 hours ago`, `last month`).
1800pub(crate) fn fmt_relative(secs: i64) -> String {
1801 relative_to(secs, OffsetDateTime::now_utc().unix_timestamp())
1802}
1803
1804fn relative_to(secs: i64, now: i64) -> String {
1805 fn ago(n: i64, one: &str, unit: &str) -> String {
1806 if n == 1 {
1807 one.to_string()
1808 } else {
1809 format!("{n} {unit}s ago")
1810 }
1811 }
1812 let delta = now - secs;
1813 if delta < 60 {
1814 return "just now".to_string();
1815 }
1816 let minutes = delta / 60;
1817 if minutes < 60 {
1818 return ago(minutes, "1 minute ago", "minute");
1819 }
1820 let hours = delta / 3600;
1821 if hours < 24 {
1822 return ago(hours, "1 hour ago", "hour");
1823 }
1824 let days = delta / 86_400;
1825 if days < 7 {
1826 return ago(days, "yesterday", "day");
1827 }
1828 let weeks = days / 7;
1829 if weeks < 5 {
1830 return ago(weeks, "last week", "week");
1831 }
1832 let months = days / 30;
1833 if months < 12 {
1834 return ago(months, "last month", "month");
1835 }
1836 ago(days / 365, "last year", "year")
1837}
1838
1839/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1840fn is_binary(bytes: &[u8]) -> bool {
1841 bytes.iter().take(8192).any(|&b| b == 0)
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846 use super::*;
1847
1848 #[test]
1849 fn markdown_by_extension_only() {
1850 assert!(is_markdown("README.md"));
1851 assert!(is_markdown("docs/guide.MarkDown"));
1852 assert!(!is_markdown("main.rs"));
1853 assert!(!is_markdown("md")); // no extension
1854 }
1855
1856 // Repo content is untrusted; rendered markdown must not become stored XSS.
1857 #[test]
1858 fn rendered_markdown_neutralizes_html_and_script_urls() {
1859 let out = render_markdown(
1860 "# 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",
1861 )
1862 .into_string();
1863 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
1864 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
1865 assert!(
1866 out.contains("&lt;script&gt;"),
1867 "raw HTML kept as text: {out}"
1868 );
1869 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
1870 assert!(!out.contains("data:"), "data URL dropped: {out}");
1871 assert!(
1872 out.contains(r#"href="https://example.com""#),
1873 "normal links survive: {out}"
1874 );
1875 }
1876
1877 #[test]
1878 fn relative_time_buckets() {
1879 const NOW: i64 = 1_000_000_000;
1880 let at = |delta: i64| relative_to(NOW - delta, NOW);
1881 assert_eq!(at(0), "just now");
1882 assert_eq!(at(59), "just now");
1883 assert_eq!(at(60), "1 minute ago");
1884 assert_eq!(at(45 * 60), "45 minutes ago");
1885 assert_eq!(at(3600), "1 hour ago");
1886 assert_eq!(at(23 * 3600), "23 hours ago");
1887 assert_eq!(at(86_400), "yesterday");
1888 assert_eq!(at(3 * 86_400), "3 days ago");
1889 assert_eq!(at(8 * 86_400), "last week");
1890 assert_eq!(at(20 * 86_400), "2 weeks ago");
1891 assert_eq!(at(40 * 86_400), "last month");
1892 assert_eq!(at(200 * 86_400), "6 months ago");
1893 assert_eq!(at(400 * 86_400), "last year");
1894 assert_eq!(at(900 * 86_400), "2 years ago");
1895 }
1896}