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