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