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