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