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::collections::{BTreeMap, HashMap};
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex, OnceLock};
8
9use anvil_core::{App, CiRun, Repository, SshKey, User, access, ci, repos, ssh_keys, users};
10use anvil_git::browse::{self, ChangeKind, FileChange};
11use axum::{
12 Form, Router,
13 extract::{Path, Query, State},
14 http::{StatusCode, header},
15 response::{IntoResponse, Redirect, Response},
16 routing::{get, post},
17};
18use maud::{DOCTYPE, Markup, PreEscaped, html};
19use similar::{ChangeTag, TextDiff};
20use syntect::easy::HighlightLines;
21use syntect::highlighting::{Theme, ThemeSet};
22use syntect::html::{IncludeBackground, styled_line_to_highlighted_html};
23use syntect::parsing::SyntaxSet;
24use time::OffsetDateTime;
25
26use crate::auth::{CSRF_FIELD, Csrf, CurrentUser, verify_csrf};
27
28const STYLE: &str = r#"
29:root { --fg:#1f2328; --muted:#656d76; --bg:#fff; --border:#d0d7de; --accent:#0969da; --code-bg:#f6f8fa; }
30* { box-sizing:border-box; }
31body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; color:var(--fg); background:var(--bg); }
32a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
33header.top { border-bottom:1px solid var(--border); padding:12px 0; background:var(--code-bg); }
34.container { max-width:980px; margin:0 auto; padding:0 16px; }
35header.top .container { display:flex; align-items:center; gap:12px; }
36.brand { font-weight:700; font-size:16px; color:var(--fg); }
37main { padding:24px 0; }
38h1,h2 { font-weight:600; } h1 { font-size:20px; } h2 { font-size:15px; margin:20px 0 8px; }
39.muted { color:var(--muted); }
40.repo-list { list-style:none; padding:0; margin:0; }
41.repo-list li { padding:12px 0; border-bottom:1px solid var(--border); }
42.repo-list .name { font-size:16px; font-weight:600; }
43.box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
44.box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
45.box .row:first-child { border-top:0; }
46.box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; }
47.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; }
48.box .row a.fc-msg:hover { color:var(--accent); }
49.box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; }
50.icon { width:16px; color:var(--muted); }
51table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
52table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
53table.code td.ln { text-align:right; color:var(--muted); user-select:none; width:1%; border-right:1px solid var(--border); background:var(--code-bg); }
54.clone { border:1px solid var(--border); border-radius:6px; padding:12px 16px; margin:16px 0; }
55.clone-head { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
56.clone-tabs { display:flex; gap:4px; margin-left:auto; }
57.clone-tab { font-size:12px; padding:2px 10px; border:1px solid var(--border); border-radius:2em; background:var(--bg); color:var(--muted); cursor:pointer; }
58.clone-tab.active { background:var(--accent); color:#fff; border-color:var(--accent); }
59.clone-cmd { display:flex; align-items:center; gap:8px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; }
60.clone-cmd code { flex:1; font:12px ui-monospace,monospace; user-select:all; overflow-x:auto; white-space:nowrap; }
61.copy-btn { display:inline-flex; align-items:center; background:none; border:0; color:var(--muted); cursor:pointer; padding:2px; }
62.copy-btn:hover { color:var(--fg); }
63.copied-msg { display:none; color:#1a7f37; font-size:12px; }
64.clone.copied .copied-msg { display:inline; }
65.clone.copied .copy-btn { color:#1a7f37; }
66.crumbs { margin:12px 0; font:13px ui-monospace,monospace; }
67.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); }
68.pill.active { background:var(--accent); border-color:var(--accent); color:#fff; }
69.view-toggle { margin:8px 0; }
70a.pill:hover { text-decoration:none; border-color:var(--accent); color:var(--accent); }
71.md-body { padding:8px 24px 16px; line-height:1.6; overflow-wrap:break-word; }
72.md-body h1, .md-body h2 { border-bottom:1px solid var(--border); padding-bottom:6px; }
73.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; }
74.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; }
75.md-body pre code { background:none; padding:0; font-size:inherit; }
76.md-body blockquote { border-left:4px solid var(--border); margin:0 0 12px; padding:0 14px; color:var(--muted); }
77.md-body table { border-collapse:collapse; margin:12px 0; } .md-body th, .md-body td { border:1px solid var(--border); padding:5px 10px; }
78.md-body img { max-width:100%; }
79.linkbtn { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
80.linkbtn:hover { text-decoration:underline; }
81.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; }
82.btn:hover { text-decoration:none; opacity:.92; }
83form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
84form.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; }
85form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; }
86.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; }
87.latest-commit + .box { border-radius:0 0 6px 6px; }
88.commit-list { list-style:none; padding:0; margin:0; }
89.commit-list li { padding:8px 0; border-top:1px solid var(--border); display:flex; gap:12px; align-items:baseline; }
90.commit-list li:first-child { border-top:0; }
91.sha { font:12px ui-monospace,monospace; color:var(--muted); }
92.file-diff { margin:16px 0; }
93.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; }
94.file-diff summary.head::-webkit-details-marker { display:none; }
95.file-diff summary.head::before { content:"\25B8"; color:var(--muted); }
96.file-diff[open] summary.head::before { content:"\25BE"; }
97.file-diff[open] summary.head { border-bottom:0; border-radius:6px 6px 0 0; }
98.file-diff .stat { margin-left:auto; white-space:nowrap; }
99.stat .plus { color:#1a7f37; } .stat .minus { color:#cf222e; }
100table.diff { border:1px solid var(--border); border-radius:0 0 6px 6px; }
101table.diff td.sign { width:1%; text-align:center; color:var(--muted); user-select:none; }
102table.diff tr.ins { background:#e6ffec; } table.diff tr.ins td.sign { color:#1a7f37; }
103table.diff tr.del { background:#ffebe9; } table.diff tr.del td.sign { color:#cf222e; }
104table.diff tr.gap td { background:var(--code-bg); color:var(--muted); text-align:center; padding:3px 10px; user-select:none; font-size:11px; }
105.badge { font-size:11px; border-radius:3px; padding:1px 6px; }
106.badge.add { background:#dafbe1; color:#1a7f37; } .badge.del { background:#ffebe9; color:#cf222e; } .badge.mod { background:#fff8c5; color:#7d4e00; }
107.st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
108.st.queued { background:#eaeef2; color:#656d76; } .st.running { background:#fff8c5; color:#7d4e00; }
109.st.success { background:#dafbe1; color:#1a7f37; } .st.failure, .st.error { background:#ffebe9; color:#cf222e; }
110.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; }
111footer { color:var(--muted); font-size:12px; padding:24px 0; border-top:1px solid var(--border); margin-top:32px; }
112"#;
113
114/// Clipboard icon for the clone "copy" button.
115const 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>"#;
116
117/// Delegated handlers for the clone widget: protocol toggle + copy-to-clipboard.
118/// Registered once on `document`, so it survives htmx body swaps.
119const CLONE_JS: &str = r#"
120(function(){
121 function copyText(t){
122 if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(t);
123 var ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
124 document.body.appendChild(ta); ta.focus(); ta.select();
125 try{document.execCommand('copy')}catch(e){}
126 document.body.removeChild(ta); return Promise.resolve();
127 }
128 document.addEventListener('click', function(e){
129 var tab=e.target.closest('.clone-tab');
130 if(tab){
131 var box=tab.closest('.clone'), cmd=box.dataset[tab.dataset.proto];
132 if(cmd){ box.querySelector('.clone-cmd code').textContent=cmd; }
133 box.querySelectorAll('.clone-tab').forEach(function(t){ t.classList.toggle('active', t===tab); });
134 return;
135 }
136 var copy=e.target.closest('.copy-btn');
137 if(copy){
138 var box=copy.closest('.clone');
139 copyText(box.querySelector('.clone-cmd code').textContent).then(function(){
140 box.classList.add('copied');
141 setTimeout(function(){ box.classList.remove('copied'); }, 1300);
142 });
143 }
144 });
145})();
146"#;
147
148/// Mount the web UI routes.
149pub fn routes(router: Router<App>) -> Router<App> {
150 router
151 .route("/", get(home))
152 .route("/-/settings", get(account_settings))
153 .route("/-/settings/keys", post(add_ssh_key))
154 .route("/-/settings/keys/{id}/delete", post(delete_ssh_key))
155 .route("/-/new", get(new_repo_form).post(new_repo_submit))
156 .route("/{username}", get(user_profile))
157 .route(
158 "/{owner}/{repo}/settings",
159 get(repo_settings).post(repo_settings_submit),
160 )
161 .route("/{owner}/{repo}", get(repo_index))
162 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
163 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
164 .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob))
165 .route("/{owner}/{repo}/commits/{rev}", get(commits))
166 .route("/{owner}/{repo}/commit/{id}", get(commit))
167 .route("/{owner}/{repo}/ci", get(ci_runs))
168 .route("/{owner}/{repo}/ci/{id}", get(ci_run))
169 .route("/-/static/htmx.min.js", get(htmx_js))
170}
171
172/// Serve the vendored htmx script (embedded in the binary).
173async fn htmx_js() -> Response {
174 (
175 [(
176 header::CONTENT_TYPE,
177 "application/javascript; charset=utf-8",
178 )],
179 include_str!("../assets/htmx.min.js"),
180 )
181 .into_response()
182}
183
184pub(crate) fn layout(title: &str, user: Option<&User>, body: Markup) -> Markup {
185 // Attach the session's CSRF token to every htmx request as a header, so any
186 // JS-driven action carries it without a hidden field. Omitted (no attribute)
187 // when unauthenticated. The token is hex, so it needs no JSON escaping.
188 let csrf = crate::auth::current_csrf();
189 let hx_headers = (!csrf.is_empty()).then(|| format!(r#"{{"{CSRF_FIELD}": "{csrf}"}}"#));
190 html! {
191 (DOCTYPE)
192 html lang="en" {
193 head {
194 meta charset="utf-8";
195 meta name="viewport" content="width=device-width, initial-scale=1";
196 title { (title) " · anvil" }
197 style { (PreEscaped(STYLE)) }
198 }
199 body hx-boost="true" hx-headers=[hx_headers] {
200 header.top { div.container {
201 a.brand href="/" { "anvil" }
202 span style="margin-left:auto" {
203 @match user {
204 Some(u) => {
205 a href="/-/settings" { (u.username) }
206 " · "
207 form method="post" action="/-/logout" style="display:inline" {
208 button.linkbtn type="submit" { "sign out" }
209 }
210 }
211 None => { a href="/-/login" { "sign in" } }
212 }
213 }
214 } }
215 main { div.container { (body) } }
216 footer { div.container { "anvil — a minimal git forge" } }
217 script src="/-/static/htmx.min.js" {}
218 script { (PreEscaped(CLONE_JS)) }
219 }
220 }
221 }
222}
223
224/// Hidden CSRF token field for embedding inside a mutating `<form>`.
225pub(crate) fn csrf_input(token: &str) -> Markup {
226 html! { input type="hidden" name=(CSRF_FIELD) value=(token); }
227}
228
229pub(crate) fn not_found(message: &str) -> Response {
230 (
231 StatusCode::NOT_FOUND,
232 layout(
233 "Not found",
234 None,
235 html! { h1 { "Not found" } p.muted { (message) } },
236 ),
237 )
238 .into_response()
239}
240
241pub(crate) fn server_error(err: impl std::fmt::Display) -> Response {
242 tracing::error!("ui error: {err}");
243 (
244 StatusCode::INTERNAL_SERVER_ERROR,
245 layout("Error", None, html! { h1 { "Something went wrong" } }),
246 )
247 .into_response()
248}
249
250/// Resolve `<owner>/<repo>` to its on-disk path and metadata row, enforcing read
251/// access for `viewer`. Private repos 404 for non-owners (no existence leak).
252pub(crate) async fn resolve_repo(
253 app: &App,
254 viewer: Option<&User>,
255 owner: &str,
256 name: &str,
257) -> Result<(PathBuf, Repository), Response> {
258 let owner_user = users::find_by_username(&app.db, owner)
259 .await
260 .map_err(server_error)?
261 .ok_or_else(|| not_found("no such user"))?;
262 let repo = repos::find(&app.db, owner_user.id, name)
263 .await
264 .map_err(server_error)?
265 .ok_or_else(|| not_found("no such repository"))?;
266 if !access::can_read(&repo, viewer) {
267 return Err(not_found("no such repository"));
268 }
269 let path = anvil_core::storage::repo_path(&app.config.repositories_dir(), owner, name);
270 if !path.exists() {
271 return Err(not_found("repository not found on disk"));
272 }
273 Ok((path, repo))
274}
275
276/// `GET /` — list repositories visible to the current user.
277async fn home(State(app): State<App>, CurrentUser(user): CurrentUser) -> Result<Markup, Response> {
278 let all = repos::list_all_with_owner(&app.db)
279 .await
280 .map_err(server_error)?;
281 let repos: Vec<_> = all
282 .into_iter()
283 .filter(|r| {
284 !r.is_private
285 || user
286 .as_ref()
287 .is_some_and(|u| u.id == r.owner_id || u.is_admin)
288 })
289 .collect();
290 Ok(layout(
291 "Repositories",
292 user.as_ref(),
293 html! {
294 div style="display:flex;align-items:center" {
295 h1 style="margin-right:auto" { "Repositories" }
296 @if user.is_some() { a.btn href="/-/new" { "New repository" } }
297 }
298 @if repos.is_empty() {
299 p.muted {
300 "No repositories yet. "
301 @if user.is_some() { a href="/-/new" { "Create one" } "." }
302 @else { "Sign in to create one." }
303 }
304 } @else {
305 ul.repo-list {
306 @for r in &repos {
307 li {
308 div.name {
309 a href=(format!("/{}", r.owner)) { (r.owner) }
310 "/"
311 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
312 @if r.is_private { " " span.pill { "private" } }
313 }
314 @if !r.description.is_empty() { div.muted { (r.description) } }
315 }
316 }
317 }
318 }
319 },
320 ))
321}
322
323/// `GET /{username}` — a user's profile: their repositories (public to all;
324/// private only to themselves or an admin).
325async fn user_profile(
326 State(app): State<App>,
327 CurrentUser(viewer): CurrentUser,
328 Path(username): Path<String>,
329) -> Result<Markup, Response> {
330 let owner = users::find_by_username(&app.db, &username)
331 .await
332 .map_err(server_error)?
333 .ok_or_else(|| not_found("no such user"))?;
334 let visible: Vec<_> = repos::list_by_owner(&app.db, owner.id)
335 .await
336 .map_err(server_error)?
337 .into_iter()
338 .filter(|r| access::can_read(r, viewer.as_ref()))
339 .collect();
340 let is_self = viewer.as_ref().is_some_and(|u| u.id == owner.id);
341
342 Ok(layout(
343 &owner.username,
344 viewer.as_ref(),
345 html! {
346 div style="display:flex;align-items:center" {
347 h1 style="margin-right:auto" { (owner.username) }
348 @if is_self { a.btn href="/-/new" { "New repository" } }
349 }
350 h2 { "Repositories" }
351 @if visible.is_empty() {
352 p.muted { "No repositories." }
353 } @else {
354 ul.repo-list {
355 @for r in &visible {
356 li {
357 div.name {
358 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
359 @if r.is_private { " " span.pill { "private" } }
360 }
361 @if !r.description.is_empty() { div.muted { (r.description) } }
362 }
363 }
364 }
365 }
366 },
367 ))
368}
369
370#[derive(serde::Deserialize)]
371struct AddKeyForm {
372 #[serde(default)]
373 title: String,
374 key: String,
375 #[serde(default)]
376 csrf: String,
377}
378
379/// `GET /settings` — account settings: profile + SSH keys.
380async fn account_settings(
381 State(app): State<App>,
382 CurrentUser(user): CurrentUser,
383 csrf: Csrf,
384) -> Response {
385 let Some(user) = user else {
386 return Redirect::to("/-/login").into_response();
387 };
388 let keys = match ssh_keys::list_by_user(&app.db, user.id).await {
389 Ok(keys) => keys,
390 Err(e) => return server_error(e),
391 };
392 account_page(&user, &keys, None, &csrf.0).into_response()
393}
394
395/// `POST /settings/keys` — register an SSH public key for the current user.
396async fn add_ssh_key(
397 State(app): State<App>,
398 CurrentUser(user): CurrentUser,
399 csrf: Csrf,
400 Form(form): Form<AddKeyForm>,
401) -> Response {
402 let Some(user) = user else {
403 return Redirect::to("/-/login").into_response();
404 };
405 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
406 return resp;
407 }
408 let result = match ssh_keys::parse_public_key(&form.key) {
409 Ok((fingerprint, content)) => {
410 ssh_keys::add(&app.db, user.id, &form.title, &fingerprint, &content)
411 .await
412 .map(|_| ())
413 }
414 Err(e) => Err(e),
415 };
416 match result {
417 Ok(()) => Redirect::to("/-/settings").into_response(),
418 Err(e) => {
419 let keys = ssh_keys::list_by_user(&app.db, user.id)
420 .await
421 .unwrap_or_default();
422 (
423 StatusCode::BAD_REQUEST,
424 account_page(&user, &keys, Some(&e.to_string()), &csrf.0),
425 )
426 .into_response()
427 }
428 }
429}
430
431/// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
432async fn delete_ssh_key(
433 State(app): State<App>,
434 CurrentUser(user): CurrentUser,
435 csrf: Csrf,
436 Path(id): Path<i64>,
437 Form(form): Form<crate::auth::CsrfForm>,
438) -> Response {
439 let Some(user) = user else {
440 return Redirect::to("/-/login").into_response();
441 };
442 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
443 return resp;
444 }
445 if let Err(e) = ssh_keys::delete(&app.db, id, user.id).await {
446 return server_error(e);
447 }
448 Redirect::to("/-/settings").into_response()
449}
450
451fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup {
452 layout(
453 "Account settings",
454 Some(user),
455 html! {
456 h1 { "Account settings" }
457 p.muted {
458 "Signed in as " strong { (user.username) }
459 @if !user.email.is_empty() { " · " (user.email) }
460 }
461
462 h2 { "SSH keys" }
463 p.muted { "Add a public key to clone and push over SSH." }
464 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
465 @if keys.is_empty() {
466 p.muted { "No SSH keys yet." }
467 } @else {
468 div.box {
469 @for k in keys {
470 div.row {
471 div {
472 @if !k.title.is_empty() { strong { (k.title) } " " }
473 span.sha { (k.fingerprint) }
474 div.muted style="font-size:12px" { "added " (fmt_time(k.created_at)) }
475 }
476 form method="post" action=(format!("/-/settings/keys/{}/delete", k.id)) {
477 (csrf_input(csrf))
478 button.linkbtn type="submit" { "delete" }
479 }
480 }
481 }
482 }
483 }
484
485 form.stack method="post" action="/-/settings/keys" style="margin-top:16px" {
486 (csrf_input(csrf))
487 p { label { "Title" br; input type="text" name="title" placeholder="laptop"; } }
488 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
489 p { button.btn type="submit" { "Add SSH key" } }
490 }
491 },
492 )
493}
494
495fn forbidden() -> Response {
496 (
497 StatusCode::FORBIDDEN,
498 layout(
499 "Forbidden",
500 None,
501 html! { h1 { "Forbidden" } p.muted { "You don't have access to this." } },
502 ),
503 )
504 .into_response()
505}
506
507#[derive(serde::Deserialize)]
508struct NewRepoForm {
509 name: String,
510 #[serde(default)]
511 description: String,
512 private: Option<String>,
513 #[serde(default)]
514 csrf: String,
515}
516
517#[derive(serde::Deserialize)]
518struct SettingsForm {
519 #[serde(default)]
520 description: String,
521 private: Option<String>,
522 #[serde(default)]
523 csrf: String,
524}
525
526/// `GET /new` — new-repository form (requires login).
527async fn new_repo_form(CurrentUser(user): CurrentUser, csrf: Csrf) -> Response {
528 let Some(user) = user else {
529 return Redirect::to("/-/login").into_response();
530 };
531 new_repo_page(&user, None, "", "", false, &csrf.0).into_response()
532}
533
534/// `POST /new` — create a repository owned by the current user.
535async fn new_repo_submit(
536 State(app): State<App>,
537 CurrentUser(user): CurrentUser,
538 csrf: Csrf,
539 Form(form): Form<NewRepoForm>,
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 private = form.private.is_some();
548 match repos::create(
549 &app.db,
550 &app.config.repositories_dir(),
551 &user,
552 &form.name,
553 &form.description,
554 private,
555 )
556 .await
557 {
558 Ok(repo) => Redirect::to(&format!("/{}/{}", user.username, repo.name)).into_response(),
559 Err(e) => (
560 StatusCode::BAD_REQUEST,
561 new_repo_page(
562 &user,
563 Some(&e.to_string()),
564 &form.name,
565 &form.description,
566 private,
567 &csrf.0,
568 ),
569 )
570 .into_response(),
571 }
572}
573
574fn new_repo_page(
575 user: &User,
576 error: Option<&str>,
577 name: &str,
578 description: &str,
579 private: bool,
580 csrf: &str,
581) -> Markup {
582 layout(
583 "New repository",
584 Some(user),
585 html! {
586 h1 { "New repository" }
587 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
588 form.stack method="post" action="/-/new" {
589 (csrf_input(csrf))
590 p { label { "Name" br; input type="text" name="name" value=(name) placeholder="my-project" autofocus; } }
591 p { label { "Description" br; input type="text" name="description" value=(description); } }
592 p { label.check { input type="checkbox" name="private" value="on" checked[private]; span { "Private — only you can see and push to it" } } }
593 p { button.btn type="submit" { "Create repository" } }
594 }
595 p.muted { "It will be created at " code { (user.username) "/" (if name.is_empty() { "<name>" } else { name }) } "." }
596 },
597 )
598}
599
600/// Load a repo for an owner-only settings action, enforcing write access.
601async fn resolve_for_settings(
602 app: &App,
603 viewer: Option<&User>,
604 owner: &str,
605 name: &str,
606) -> Result<Repository, Response> {
607 let owner_user = users::find_by_username(&app.db, owner)
608 .await
609 .map_err(server_error)?
610 .ok_or_else(|| not_found("no such repository"))?;
611 let repo = repos::find(&app.db, owner_user.id, name)
612 .await
613 .map_err(server_error)?
614 .ok_or_else(|| not_found("no such repository"))?;
615 if !access::can_read(&repo, viewer) {
616 return Err(not_found("no such repository"));
617 }
618 if !access::can_write(&repo, viewer) {
619 return Err(forbidden());
620 }
621 Ok(repo)
622}
623
624/// `GET /{owner}/{repo}/settings` — owner-only repository settings.
625async fn repo_settings(
626 State(app): State<App>,
627 CurrentUser(user): CurrentUser,
628 csrf: Csrf,
629 Path((owner, repo)): Path<(String, String)>,
630) -> Response {
631 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
632 Ok(m) => m,
633 Err(resp) => return resp,
634 };
635 settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
636}
637
638/// `POST /{owner}/{repo}/settings` — update description / visibility.
639async fn repo_settings_submit(
640 State(app): State<App>,
641 CurrentUser(user): CurrentUser,
642 csrf: Csrf,
643 Path((owner, repo)): Path<(String, String)>,
644 Form(form): Form<SettingsForm>,
645) -> Response {
646 let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await {
647 Ok(m) => m,
648 Err(resp) => return resp,
649 };
650 if let Err(resp) = verify_csrf(&csrf, &form.csrf) {
651 return resp;
652 }
653 if let Err(e) =
654 repos::update_settings(&app.db, meta.id, &form.description, form.private.is_some()).await
655 {
656 return server_error(e);
657 }
658 Redirect::to(&format!("/{owner}/{repo}")).into_response()
659}
660
661fn settings_page(
662 user: Option<&User>,
663 owner: &str,
664 repo: &str,
665 meta: &Repository,
666 error: Option<&str>,
667 csrf: &str,
668) -> Markup {
669 layout(
670 &format!("{owner}/{repo}: settings"),
671 user,
672 html! {
673 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · settings" }
674 @if let Some(error) = error { p style="color:#cf222e" { (error) } }
675 form.stack method="post" action=(format!("/{owner}/{repo}/settings")) {
676 (csrf_input(csrf))
677 p { label { "Description" br; input type="text" name="description" value=(meta.description); } }
678 p { label.check { input type="checkbox" name="private" value="on" checked[meta.is_private]; span { "Private — only you can see and push to it" } } }
679 p { button.btn type="submit" { "Save changes" } }
680 }
681 },
682 )
683}
684
685fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
686 let http = app.config.http_clone_url(owner, name);
687 let ssh = app
688 .config
689 .ssh
690 .enabled
691 .then(|| app.config.ssh_clone_url(owner, name));
692 html! {
693 div.clone data-http=(format!("git clone {http}")) data-ssh=[ssh.as_ref().map(|s| format!("git clone {s}"))] {
694 div.clone-head {
695 span.muted { "Clone" }
696 div.clone-tabs {
697 button.clone-tab.active type="button" data-proto="http" { "HTTP" }
698 @if ssh.is_some() {
699 button.clone-tab type="button" data-proto="ssh" { "SSH" }
700 }
701 }
702 }
703 div.clone-cmd {
704 code { "git clone " (http) }
705 button.copy-btn type="button" title="Copy to clipboard" aria-label="Copy" {
706 (PreEscaped(CLIPBOARD_SVG))
707 }
708 span.copied-msg { "Copied!" }
709 }
710 }
711 }
712}
713
714/// `GET /{owner}/{repo}` — repository overview with the root tree.
715async fn repo_index(
716 State(app): State<App>,
717 CurrentUser(user): CurrentUser,
718 Path((owner, repo)): Path<(String, String)>,
719) -> Result<Markup, Response> {
720 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
721 let overview = browse::overview(&path).map_err(server_error)?;
722
723 let can_write = access::can_write(&meta, user.as_ref());
724 let header = html! {
725 div style="display:flex;align-items:center;gap:8px" {
726 h1 style="margin-right:auto" {
727 a href=(format!("/{owner}")) { (owner) } " / " (repo)
728 @if meta.is_private { " " span.pill { "private" } }
729 }
730 a.btn href=(format!("/{owner}/{repo}/ci")) { "CI" }
731 a.btn href=(format!("/{owner}/{repo}/pages")) { "Pages" }
732 @if can_write {
733 a.btn href=(format!("/{owner}/{repo}/settings")) { "Settings" }
734 }
735 }
736 @if !meta.description.is_empty() { p.muted { (meta.description) } }
737 p {
738 span.pill { (overview.branches.len()) " branches" }
739 " "
740 span.pill { (overview.tags.len()) " tags" }
741 }
742 (clone_box(&app, &owner, &repo))
743 };
744
745 if overview.is_empty {
746 return Ok(layout(
747 &format!("{owner}/{repo}"),
748 user.as_ref(),
749 html! {
750 (header)
751 p.muted { "This repository is empty. Push to it to get started." }
752 },
753 ));
754 }
755
756 let rev = overview
757 .default_branch
758 .clone()
759 .unwrap_or_else(|| "HEAD".to_string());
760 let entries = browse::list_tree(&path, &rev, "").map_err(server_error)?;
761 let latest = browse::commit_log(&path, &rev, 1)
762 .map_err(server_error)?
763 .into_iter()
764 .next();
765 // Best-effort: a failed walk only costs the per-entry annotations.
766 let entry_commits =
767 browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default();
768
769 Ok(layout(
770 &format!("{owner}/{repo}"),
771 user.as_ref(),
772 html! {
773 (header)
774 p.muted {
775 "Branch: " (rev) " · "
776 a href=(format!("/{owner}/{repo}/commits/{rev}")) { "commits" }
777 }
778 @if let Some(c) = &latest {
779 div.latest-commit {
780 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
781 a href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.summary) }
782 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
783 }
784 }
785 (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
786 },
787 ))
788}
789
790async fn tree_root(
791 State(app): State<App>,
792 user: CurrentUser,
793 Path((owner, repo, rev)): Path<(String, String, String)>,
794) -> Result<Markup, Response> {
795 render_tree(&app, user, &owner, &repo, &rev, "").await
796}
797
798async fn tree_path(
799 State(app): State<App>,
800 user: CurrentUser,
801 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
802) -> Result<Markup, Response> {
803 render_tree(&app, user, &owner, &repo, &rev, &path).await
804}
805
806async fn render_tree(
807 app: &App,
808 CurrentUser(user): CurrentUser,
809 owner: &str,
810 repo: &str,
811 rev: &str,
812 path: &str,
813) -> Result<Markup, Response> {
814 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
815 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
816 // Best-effort: a failed walk only costs the per-entry annotations.
817 let entry_commits =
818 browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default();
819 Ok(layout(
820 &format!("{owner}/{repo}: {path}"),
821 user.as_ref(),
822 html! {
823 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
824 (breadcrumbs(owner, repo, rev, path, false))
825 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
826 },
827 ))
828}
829
830/// `GET /{owner}/{repo}/blob/{rev}/{*path}` — view a file. Markdown renders
831/// by default; `?plain=1` shows the raw source (toggle links on the page).
832async fn blob(
833 State(app): State<App>,
834 CurrentUser(user): CurrentUser,
835 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
836 Query(query): Query<HashMap<String, String>>,
837) -> Result<Markup, Response> {
838 let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
839 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
840 .map_err(server_error)?
841 .ok_or_else(|| not_found("file not found"))?;
842
843 let markdown = is_markdown(&path) && !is_binary(&bytes);
844 let rendered = markdown && !query.contains_key("plain");
845
846 let body = if is_binary(&bytes) {
847 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
848 } else if rendered {
849 let text = String::from_utf8_lossy(&bytes);
850 html! { div.md-body { (render_markdown(&text)) } }
851 } else {
852 let text = String::from_utf8_lossy(&bytes);
853 let budget = app.config.http.highlight_cache_mb.saturating_mul(1 << 20);
854 let lines = cached_highlight(budget, &oid, &path, &text);
855 html! {
856 table.code {
857 @for (i, line) in lines.iter().enumerate() {
858 tr {
859 td.ln { (i + 1) }
860 td { (PreEscaped(line)) }
861 }
862 }
863 }
864 }
865 };
866
867 let blob_url = format!("/{owner}/{repo}/blob/{rev}/{path}");
868 Ok(layout(
869 &format!("{owner}/{repo}: {path}"),
870 user.as_ref(),
871 html! {
872 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
873 (breadcrumbs(&owner, &repo, &rev, &path, true))
874 @if markdown {
875 p.view-toggle {
876 @if rendered {
877 span.pill.active { "Rendered" } " "
878 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
879 } @else {
880 a.pill href=(blob_url) { "Rendered" } " "
881 span.pill.active { "Source" }
882 }
883 }
884 }
885 div.box style="overflow-x:auto" { (body) }
886 },
887 ))
888}
889
890/// Whether a path should be treated as markdown (by extension).
891fn is_markdown(path: &str) -> bool {
892 std::path::Path::new(path)
893 .extension()
894 .and_then(|e| e.to_str())
895 .is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
896}
897
898/// Render markdown to HTML (tables, strikethrough, task lists, footnotes).
899///
900/// Repo content is untrusted, so this is a stored-XSS surface: raw HTML in the
901/// source is emitted as escaped literal text, and `javascript:`/`data:`-style
902/// link and image destinations are dropped.
903fn render_markdown(text: &str) -> Markup {
904 use pulldown_cmark::{Event, Options, Parser, Tag, html};
905
906 fn safe_url(dest: &str) -> bool {
907 let d = dest.trim().to_ascii_lowercase();
908 !(d.starts_with("javascript:") || d.starts_with("data:") || d.starts_with("vbscript:"))
909 }
910
911 let opts = Options::ENABLE_TABLES
912 | Options::ENABLE_STRIKETHROUGH
913 | Options::ENABLE_TASKLISTS
914 | Options::ENABLE_FOOTNOTES;
915 let events = Parser::new_ext(text, opts).map(|ev| match ev {
916 Event::Html(h) => Event::Text(h),
917 Event::InlineHtml(h) => Event::Text(h),
918 Event::Start(Tag::Link {
919 link_type,
920 dest_url,
921 title,
922 id,
923 }) if !safe_url(&dest_url) => Event::Start(Tag::Link {
924 link_type,
925 dest_url: "".into(),
926 title,
927 id,
928 }),
929 Event::Start(Tag::Image {
930 link_type,
931 dest_url,
932 title,
933 id,
934 }) if !safe_url(&dest_url) => Event::Start(Tag::Image {
935 link_type,
936 dest_url: "".into(),
937 title,
938 id,
939 }),
940 e => e,
941 });
942 let mut out = String::new();
943 html::push_html(&mut out, events);
944 PreEscaped(out)
945}
946
947/// How far back the per-entry "latest commit" walk looks. Entries last touched
948/// beyond this many commits just lose the annotation.
949const ENTRY_LOG_WALK: usize = 400;
950
951/// Render a tree listing as a box of rows; directories link to `tree`, files to
952/// `blob`. Each entry also shows the subject of (and links to) the latest
953/// commit that touched it, when `latest` has one for it.
954fn tree_table(
955 owner: &str,
956 repo: &str,
957 rev: &str,
958 path: &str,
959 entries: &[browse::TreeEntry],
960 latest: &BTreeMap<String, browse::CommitInfo>,
961) -> Markup {
962 let join = |name: &str| {
963 if path.is_empty() {
964 name.to_string()
965 } else {
966 format!("{path}/{name}")
967 }
968 };
969 html! {
970 div.box {
971 @if !path.is_empty() {
972 div.row {
973 a.entry href=(parent_link(owner, repo, rev, path)) { span.icon { ".." } "up" }
974 }
975 }
976 @for e in entries {
977 @let child = join(&e.name);
978 @let kind = if e.is_dir { "tree" } else { "blob" };
979 div.row {
980 a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) {
981 span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
982 (e.name) @if e.is_dir { "/" }
983 }
984 @if let Some(c) = latest.get(&e.name) {
985 a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) }
986 span.fc-time { (fmt_date(c.time)) }
987 }
988 }
989 }
990 }
991 }
992}
993
994fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
995 match path.rsplit_once('/') {
996 Some((parent, _)) => format!("/{owner}/{repo}/tree/{rev}/{parent}"),
997 None => format!("/{owner}/{repo}/tree/{rev}"),
998 }
999}
1000
1001/// Path breadcrumbs. `is_blob` marks the final component as a file.
1002fn breadcrumbs(owner: &str, repo: &str, rev: &str, path: &str, is_blob: bool) -> Markup {
1003 // Precompute (label, cumulative_path) for each path component.
1004 let mut crumbs: Vec<(String, String)> = Vec::new();
1005 let mut acc = String::new();
1006 for part in path.split('/').filter(|p| !p.is_empty()) {
1007 if !acc.is_empty() {
1008 acc.push('/');
1009 }
1010 acc.push_str(part);
1011 crumbs.push((part.to_string(), acc.clone()));
1012 }
1013 let last = crumbs.len();
1014 html! {
1015 div.crumbs {
1016 a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) }
1017 @for (i, (label, cum)) in crumbs.iter().enumerate() {
1018 " / "
1019 @if i + 1 == last && is_blob {
1020 span { (label) }
1021 } @else {
1022 a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) }
1023 }
1024 }
1025 }
1026 }
1027}
1028
1029/// `GET /{owner}/{repo}/commits/{rev}` — commit history.
1030async fn commits(
1031 State(app): State<App>,
1032 CurrentUser(user): CurrentUser,
1033 Path((owner, repo, rev)): Path<(String, String, String)>,
1034) -> Result<Markup, Response> {
1035 let (path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1036 let log = browse::commit_log(&path, &rev, 100).map_err(server_error)?;
1037
1038 // Map each commit oid to its latest run status, for inline badges. One query
1039 // for the repo's recent runs; first match wins (list is newest-first).
1040 let runs = ci::list_by_repo(&app.db, meta.id, 200)
1041 .await
1042 .unwrap_or_default();
1043 let mut status_of: HashMap<&str, &str> = HashMap::new();
1044 for r in &runs {
1045 status_of
1046 .entry(r.commit.as_str())
1047 .or_insert(r.status.as_str());
1048 }
1049
1050 Ok(layout(
1051 &format!("{owner}/{repo}: commits"),
1052 user.as_ref(),
1053 html! {
1054 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · commits" }
1055 ul.commit-list {
1056 @for c in &log {
1057 li {
1058 a.sha href=(format!("/{owner}/{repo}/commit/{}", c.id)) { (c.short) }
1059 @if let Some(st) = status_of.get(c.id.as_str()) {
1060 a href=(format!("/{owner}/{repo}/ci")) { (status_badge(st)) }
1061 }
1062 span { (c.summary) }
1063 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
1064 }
1065 }
1066 }
1067 },
1068 ))
1069}
1070
1071/// `GET /{owner}/{repo}/commit/{id}` — a commit with its diff.
1072async fn commit(
1073 State(app): State<App>,
1074 CurrentUser(user): CurrentUser,
1075 Path((owner, repo, id)): Path<(String, String, String)>,
1076) -> Result<Markup, Response> {
1077 let (path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1078 let detail = browse::commit_detail(&path, &id).map_err(server_error)?;
1079 Ok(layout(
1080 &format!("{owner}/{repo}: {}", detail.info.short),
1081 user.as_ref(),
1082 html! {
1083 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · " span.sha { (detail.info.short) } }
1084 p { (detail.info.summary) }
1085 p.muted {
1086 (detail.info.author) " · " (fmt_time(detail.info.time)) " · "
1087 span.sha { (detail.info.id) }
1088 @if let Some(parent) = &detail.parent {
1089 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
1090 }
1091 }
1092 @if detail.changes.is_empty() {
1093 p.muted { "No file changes." }
1094 }
1095 @for change in &detail.changes {
1096 (render_file_diff(change))
1097 }
1098 },
1099 ))
1100}
1101
1102/// `GET /{owner}/{repo}/ci` — recent CI runs for the repository.
1103async fn ci_runs(
1104 State(app): State<App>,
1105 CurrentUser(user): CurrentUser,
1106 Path((owner, repo)): Path<(String, String)>,
1107) -> Result<Markup, Response> {
1108 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1109 let runs = ci::list_by_repo(&app.db, meta.id, 100)
1110 .await
1111 .map_err(server_error)?;
1112 Ok(layout(
1113 &format!("{owner}/{repo}: CI"),
1114 user.as_ref(),
1115 html! {
1116 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } " · CI" }
1117 @if runs.is_empty() {
1118 p.muted {
1119 "No CI runs yet. Add a " code { ".anvil/ci.yml" }
1120 " pipeline and push to trigger one."
1121 }
1122 } @else {
1123 div.box {
1124 @for r in &runs {
1125 div.row {
1126 a.entry href=(format!("/{owner}/{repo}/ci/{}", r.id)) {
1127 (status_badge(&r.status))
1128 span.sha { (short_commit(&r.commit)) }
1129 span { (r.ref_name) }
1130 }
1131 span.muted { (fmt_time(r.created_at)) }
1132 }
1133 }
1134 }
1135 }
1136 },
1137 ))
1138}
1139
1140/// `GET /{owner}/{repo}/ci/{id}` — one run's status, timing, and log output.
1141async fn ci_run(
1142 State(app): State<App>,
1143 CurrentUser(user): CurrentUser,
1144 Path((owner, repo, id)): Path<(String, String, i64)>,
1145) -> Result<Markup, Response> {
1146 let (_, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?;
1147 let run = ci::get(&app.db, id)
1148 .await
1149 .map_err(server_error)?
1150 .filter(|r| r.repo_id == meta.id)
1151 .ok_or_else(|| not_found("no such CI run"))?;
1152 Ok(layout(
1153 &format!("{owner}/{repo}: CI #{}", run.id),
1154 user.as_ref(),
1155 html! {
1156 h1 {
1157 a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) }
1158 " · " a href=(format!("/{owner}/{repo}/ci")) { "CI" }
1159 " · #" (run.id)
1160 }
1161 p {
1162 (status_badge(&run.status))
1163 " "
1164 a.sha href=(format!("/{owner}/{repo}/commit/{}", run.commit)) { (short_commit(&run.commit)) }
1165 " " span.muted { (run.ref_name) }
1166 }
1167 p.muted {
1168 "queued " (fmt_time(run.created_at))
1169 @if run.started_at > 0 { " · started " (fmt_time(run.started_at)) }
1170 @if run.finished_at > 0 { " · finished " (fmt_time(run.finished_at)) }
1171 @if let Some(d) = run_duration(&run) { " · took " (d) }
1172 }
1173 @if run.log.is_empty() {
1174 p.muted { "No output yet." }
1175 } @else {
1176 pre.log { (run.log) }
1177 }
1178 },
1179 ))
1180}
1181
1182/// A coloured status pill for a CI run status string.
1183fn status_badge(status: &str) -> Markup {
1184 html! { span class=(format!("st {status}")) { (status) } }
1185}
1186
1187/// First 8 hex chars of a commit oid (for compact display).
1188fn short_commit(commit: &str) -> &str {
1189 &commit[..commit.len().min(8)]
1190}
1191
1192/// Wall-clock run duration (`started`→`finished`) as a short string, if known.
1193fn run_duration(run: &CiRun) -> Option<String> {
1194 if run.started_at > 0 && run.finished_at >= run.started_at {
1195 Some(format!("{}s", run.finished_at - run.started_at))
1196 } else {
1197 None
1198 }
1199}
1200
1201/// Render one file's diff (added/deleted/modified) as a unified line diff.
1202/// A file diff bigger than this many rows starts collapsed (its header still
1203/// shows the +/− counts; clicking expands it — native `details`, no JS).
1204const DIFF_COLLAPSE_ROWS: usize = 400;
1205
1206fn render_file_diff(change: &FileChange) -> Markup {
1207 let (badge_cls, badge) = match change.kind {
1208 ChangeKind::Added => ("add", "added"),
1209 ChangeKind::Deleted => ("del", "deleted"),
1210 ChangeKind::Modified => ("mod", "modified"),
1211 };
1212 let head = |stat: Markup| {
1213 html! {
1214 summary.head {
1215 span class=(format!("badge {badge_cls}")) { (badge) }
1216 span { (change.path) }
1217 span.stat { (stat) }
1218 }
1219 }
1220 };
1221
1222 let binary = change.old.as_deref().is_some_and(is_binary)
1223 || change.new.as_deref().is_some_and(is_binary);
1224 if binary {
1225 return html! {
1226 details.file-diff open {
1227 (head(html! { span.muted { "binary" } }))
1228 div.box { div.row { span.muted { "Binary file" } } }
1229 }
1230 };
1231 }
1232
1233 let old = change
1234 .old
1235 .as_deref()
1236 .map(|b| String::from_utf8_lossy(b).into_owned())
1237 .unwrap_or_default();
1238 let new = change
1239 .new
1240 .as_deref()
1241 .map(|b| String::from_utf8_lossy(b).into_owned())
1242 .unwrap_or_default();
1243 let diff = TextDiff::from_lines(&old, &new);
1244 let (mut adds, mut dels) = (0usize, 0usize);
1245 for c in diff.iter_all_changes() {
1246 match c.tag() {
1247 ChangeTag::Insert => adds += 1,
1248 ChangeTag::Delete => dels += 1,
1249 ChangeTag::Equal => {}
1250 }
1251 }
1252 // Hunks: changed lines plus 3 lines of context, not the whole file.
1253 let groups = diff.grouped_ops(3);
1254 let rendered_rows: usize = groups
1255 .iter()
1256 .flatten()
1257 .map(|op| diff.iter_changes(op).count())
1258 .sum();
1259
1260 html! {
1261 details.file-diff open[rendered_rows <= DIFF_COLLAPSE_ROWS] {
1262 (head(html! { span.plus { "+" (adds) } " " span.minus { "−" (dels) } }))
1263 (diff_table(&diff, &groups, old.lines().count()))
1264 }
1265 }
1266}
1267
1268/// Render grouped diff hunks as a table: old/new line numbers, a +/- sign
1269/// column, and the line. Elided stretches show a "⋯ N unchanged lines" row
1270/// (including before the first hunk and after the last).
1271fn diff_table<'a>(
1272 diff: &TextDiff<'a, 'a, '_, str>,
1273 groups: &[Vec<similar::DiffOp>],
1274 old_total: usize,
1275) -> Markup {
1276 let gap_row = |n: usize| {
1277 html! {
1278 @if n > 0 {
1279 tr.gap { td colspan="4" { "⋯ " (n) " unchanged line" @if n != 1 { "s" } } }
1280 }
1281 }
1282 };
1283 // Unchanged-line gap before each group, and after the last one.
1284 let mut prev_end = 0usize; // end of the previous group, in old-file lines
1285 let mut with_gaps = Vec::with_capacity(groups.len());
1286 for group in groups {
1287 let start = group.first().map_or(prev_end, |op| op.old_range().start);
1288 with_gaps.push((start.saturating_sub(prev_end), group));
1289 prev_end = group.last().map_or(prev_end, |op| op.old_range().end);
1290 }
1291 let trailing = old_total.saturating_sub(prev_end);
1292
1293 html! {
1294 table.code.diff {
1295 @for (gap, group) in &with_gaps {
1296 (gap_row(*gap))
1297 @for op in group.iter() {
1298 @for change in diff.iter_changes(op) {
1299 @let (sign, cls) = match change.tag() {
1300 ChangeTag::Delete => ("-", "del"),
1301 ChangeTag::Insert => ("+", "ins"),
1302 ChangeTag::Equal => (" ", ""),
1303 };
1304 tr class=(cls) {
1305 td.ln { @if let Some(i) = change.old_index() { (i + 1) } }
1306 td.ln { @if let Some(i) = change.new_index() { (i + 1) } }
1307 td.sign { (sign) }
1308 td { (change.value().trim_end_matches('\n')) }
1309 }
1310 }
1311 }
1312 }
1313 (gap_row(trailing))
1314 }
1315 }
1316}
1317
1318/// Lazily-loaded syntax set and theme (pure-Rust fancy-regex backend).
1319fn highlighter() -> &'static (SyntaxSet, Theme) {
1320 static HL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new();
1321 HL.get_or_init(|| {
1322 let syntaxes = SyntaxSet::load_defaults_newlines();
1323 let themes = ThemeSet::load_defaults();
1324 let theme = themes
1325 .themes
1326 .get("InspiredGitHub")
1327 .or_else(|| themes.themes.values().next())
1328 .cloned()
1329 .expect("at least one default theme");
1330 (syntaxes, theme)
1331 })
1332}
1333
1334/// [`highlight`] through a byte-budgeted LRU keyed by blob oid + extension: a
1335/// blob's rendered HTML is immutable for its object id (the extension is part
1336/// of the key because it picks the syntax), so each file is highlighted once
1337/// rather than once per request — highlighting large files is by far the most
1338/// expensive thing a page view can do. The budget is
1339/// `http.highlight_cache_mb`; `0` bypasses the cache entirely (for
1340/// RAM-constrained hosts). Concurrent misses may both compute and the last
1341/// insert wins; that's benign.
1342fn cached_highlight(budget_bytes: usize, oid: &str, path: &str, text: &str) -> Arc<Vec<String>> {
1343 if budget_bytes == 0 {
1344 return Arc::new(highlight(path, text));
1345 }
1346 struct Cache {
1347 lru: lru::LruCache<String, Arc<Vec<String>>>,
1348 bytes: usize,
1349 }
1350 fn cost(key: &str, lines: &[String]) -> usize {
1351 key.len() + lines.iter().map(String::len).sum::<usize>()
1352 }
1353 static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
1354 let cache = CACHE.get_or_init(|| {
1355 Mutex::new(Cache {
1356 lru: lru::LruCache::unbounded(),
1357 bytes: 0,
1358 })
1359 });
1360
1361 let ext = std::path::Path::new(path)
1362 .extension()
1363 .and_then(|e| e.to_str())
1364 .unwrap_or("");
1365 let key = format!("{oid}\x00{ext}");
1366 if let Some(hit) = cache.lock().expect("cache lock").lru.get(&key) {
1367 return hit.clone();
1368 }
1369
1370 let lines = Arc::new(highlight(path, text));
1371 let mut c = cache.lock().expect("cache lock");
1372 c.bytes += cost(&key, &lines);
1373 if let Some(old) = c.lru.put(key.clone(), Arc::clone(&lines)) {
1374 c.bytes -= cost(&key, &old); // concurrent miss inserted it first
1375 }
1376 // Evict oldest entries until we're back under budget. An entry larger than
1377 // the whole budget evicts itself — memory stays bounded, it just never caches.
1378 while c.bytes > budget_bytes {
1379 let Some((k, v)) = c.lru.pop_lru() else { break };
1380 c.bytes -= cost(&k, &v);
1381 }
1382 lines
1383}
1384
1385/// Syntax-highlight `text` (chosen by file extension), returning per-line HTML.
1386/// Falls back to escaped plain text for large files or on any failure.
1387fn highlight(path: &str, text: &str) -> Vec<String> {
1388 if text.len() > 512 * 1024 {
1389 return text.lines().map(escape).collect();
1390 }
1391 let (syntaxes, theme) = highlighter();
1392 let syntax = std::path::Path::new(path)
1393 .extension()
1394 .and_then(|e| e.to_str())
1395 .and_then(|ext| syntaxes.find_syntax_by_extension(ext))
1396 .or_else(|| syntaxes.find_syntax_by_first_line(text.lines().next().unwrap_or("")))
1397 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
1398
1399 let mut h = HighlightLines::new(syntax, theme);
1400 text.lines()
1401 .map(|line| match h.highlight_line(line, syntaxes) {
1402 Ok(ranges) => styled_line_to_highlighted_html(&ranges, IncludeBackground::No)
1403 .unwrap_or_else(|_| escape(line)),
1404 Err(_) => escape(line),
1405 })
1406 .collect()
1407}
1408
1409fn escape(s: &str) -> String {
1410 s.replace('&', "&amp;")
1411 .replace('<', "&lt;")
1412 .replace('>', "&gt;")
1413}
1414
1415/// Format a Unix timestamp as `YYYY-MM-DD HH:MM UTC`.
1416fn fmt_time(secs: i64) -> String {
1417 match OffsetDateTime::from_unix_timestamp(secs) {
1418 Ok(t) => format!(
1419 "{:04}-{:02}-{:02} {:02}:{:02} UTC",
1420 t.year(),
1421 u8::from(t.month()),
1422 t.day(),
1423 t.hour(),
1424 t.minute()
1425 ),
1426 Err(_) => secs.to_string(),
1427 }
1428}
1429
1430/// Format a Unix timestamp as a bare `YYYY-MM-DD` (for compact tree rows).
1431fn fmt_date(secs: i64) -> String {
1432 match OffsetDateTime::from_unix_timestamp(secs) {
1433 Ok(t) => format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()),
1434 Err(_) => secs.to_string(),
1435 }
1436}
1437
1438/// Heuristic: treat content with a NUL in the first 8 KiB as binary.
1439fn is_binary(bytes: &[u8]) -> bool {
1440 bytes.iter().take(8192).any(|&b| b == 0)
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446
1447 #[test]
1448 fn markdown_by_extension_only() {
1449 assert!(is_markdown("README.md"));
1450 assert!(is_markdown("docs/guide.MarkDown"));
1451 assert!(!is_markdown("main.rs"));
1452 assert!(!is_markdown("md")); // no extension
1453 }
1454
1455 // Repo content is untrusted; rendered markdown must not become stored XSS.
1456 #[test]
1457 fn rendered_markdown_neutralizes_html_and_script_urls() {
1458 let out = render_markdown(
1459 "# 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",
1460 )
1461 .into_string();
1462 assert!(out.contains("<h1>title</h1>"), "markdown renders: {out}");
1463 assert!(!out.contains("<script>"), "raw HTML escaped: {out}");
1464 assert!(
1465 out.contains("&lt;script&gt;"),
1466 "raw HTML kept as text: {out}"
1467 );
1468 assert!(!out.contains("javascript:"), "script URL dropped: {out}");
1469 assert!(!out.contains("data:"), "data URL dropped: {out}");
1470 assert!(
1471 out.contains(r#"href="https://example.com""#),
1472 "normal links survive: {out}"
1473 );
1474 }
1475}