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