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